Skip to Content
Course content

135: PhantomData and Zero-Sized Types

Click on the "Edit" button in the top corner of the screen to edit your slide content.

A few years ago, I was helping a colleague build a high-performance network driver. We wanted to use the "Typestate" pattern to ensure that a socket couldn't be written to until it had been properly handshaked and authenticated. We defined a Socket<State> struct, where State would be one of several empty structs like Disconnected or Authenticated. It seemed elegant—pure compile-time safety. But the moment he tried to compile, Rust hit him with a wall of errors: "parameter `State` is never used."

He was baffled. "But I'm using it in the type signature!" he argued. The problem is that Rust's compiler isn't just checking if you're using a type for your own organizational sanity; it needs to know how that type affects variance and drop checking. If a struct has a generic parameter T, Rust expects that T actually exists somewhere inside the struct so it can determine if the struct "owns" a T or just references it. When the parameter is only used in the type signature and not in a field, the compiler sees a hole in its logic and stops you.

Telling the Compiler a Useful Lie

This is where std::marker::PhantomData comes in. Think of PhantomData as a way to tell the compiler: "I know it looks like I'm not using this type, but please act as if I am." It's a marker that occupies no space at runtime but satisfies the compiler's requirement that every generic parameter must be used within the struct definition.

use std::marker::PhantomData;

struct Disconnected;
struct Connected;

struct Socket<State> {
    address: String,
    // We don't actually store a 'State' object, 
    // but we tell Rust we 'own' one.
    _state: PhantomData<State>,
}

impl Socket<Disconnected> {
    fn connect(self) -> Socket<Connected> {
        println!("Connecting to {}...", self.address);
        Socket {
            address: self.address,
            _state: PhantomData,
        }
    }
}

impl Socket<Connected> {
    fn send(&self, msg: &str) {
        println!("Sending: {}", msg);
    }
}

fn main() {
    let socket = Socket {
        address: "127.0.0.1".to_string(),
        _state: PhantomData,
    };

    // socket.send("Hello"); // This would fail to compile!
    let connected_socket = socket.connect();
    connected_socket.send("Hello"); // This works.
}

In this example, PhantomData<State> doesn't add a single byte to the Socket struct. It's essentially a ghost. However, it allows us to implement different methods for Socket<Disconnected> and Socket<Connected>, effectively creating a state machine that is checked entirely at compile time.

The Magic of Zero-Sized Types

You might be wondering why PhantomData doesn't bloat your memory. This is because it is a Zero-Sized Type (ZST). In Rust, certain types—like () (the unit type) or empty structs—take up exactly zero bytes of RAM. When the compiler sees a ZST, it simply optimizes it away during the LLVM pass.

I've always found it fascinating that we can use types to convey complex logic to the compiler without paying any performance penalty at runtime. By combining ZSTs and PhantomData, you're essentially writing "comments" that the compiler is forced to enforce. If you've ever worked in C++, this is somewhat akin to template metaprogramming, but integrated directly into Rust's ownership and type system. It's an incredibly powerful way to prevent bugs—like calling send() on a disconnected socket—before the code even runs.




📋 Practical Task

Exercise: Building a Type-Safe Database Query Wrapper

You are building a library where users must "sanitize" a raw query string before it can be executed against a database to prevent SQL injection. You want to make it impossible to call execute() on a string that hasn't been passed through the sanitize() function.

Your Task:

  • Create two ZSTs: Raw and Sanitized.
  • Create a struct Query<State> that holds a String and uses PhantomData<State> to track its status.
  • Implement a new() method for Query<Raw> that takes a string.
  • Implement a sanitize() method for Query<Raw> that returns a Query<Sanitized> (for this exercise, just return the string as-is, but change the type).
  • Implement an execute() method only for Query<Sanitized> that prints "Executing query: [query_text]".
  • In your main function, demonstrate that a Query<Raw> cannot call execute(), but a Query<Sanitized> can.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.