22.5 Phantom Types: Encoding State in Unused Type Parameters
Right, so you’ve got branded types for making your primitives unique and opaque types for hiding implementation details. Now let’s talk about the weird cousin in the type system family: phantom types. They’re called “phantom” because they’re type parameters that exist purely at the type level—they show up in the type definition but never in the actual runtime value. They’re the ghost in the machine, and we’re going to use them to enforce state transitions at compile time so you can’t possibly screw them up.
Think about a state machine, like a network request. It can be Loading, Success, or Error. You absolutely do not want to call .data on a Loading state or .retry() on a Success state. You could do this with a bunch of conditional checks and hope you get it right every time. Or, you could make it literally impossible to write that incorrect code by letting the type system do the heavy lifting. That’s what phantom types are for.
The core idea is simple: you create a generic type that takes a parameter, and that parameter represents the state. The actual value never contains any information about that type parameter—it’s “phantom” data. The magic happens when you write functions that only accept a specific type parameter, effectively locking operations to a specific state.
The Canonical Example: A Finite State Machine
Let’s build a simple but powerful state machine for a payment process. A payment can be in Created, Authorized, or Captured states. You can’t capture funds you haven’t authorized, and you can’t refund a payment that hasn’t been captured. The compiler will now be your very strict, very pedantic business logic enforcer.
We start by defining our states as literal types. We’ll never create values of these types; they’re just markers.
// These are our state markers. They are never instantiated.
type Created = { readonly __tag: 'Created' };
type Authorized = { readonly __tag: 'Authorized' };
type Captured = { readonly __tag: 'Captured' };
Now, we create our main Payment type. It’s generic over T, which will be one of our state types. Notice that the T is not used in the actual properties. It’s purely a phantom parameter.
class Payment<T> {
// The 'amount' is the real data. 'T' is the phantom type.
constructor(public readonly amount: number) {}
// This is a getter that might only be available in certain states.
// We'll implement this safely later.
getAmount(): number {
return this.amount;
}
}
Transitioning Between States Safely
The real power comes from the functions that transition between states. They return a new Payment object with a different type parameter. Notice how the authorize function takes a Payment<Created> and returns a Payment<Authorized>. The runtime value is just a number, but the type has changed completely.
function authorize(payment: Payment<Created>): Payment<Authorized> {
// ... imagine some logic that talks to a payment processor ...
console.log(`Authorizing payment of $${payment.getAmount()}`);
// The key part: we return a new Payment instance, but the type system
// now sees it as Payment<Authorized>.
return new Payment<Authorized>(payment.amount);
}
function capture(payment: Payment<Authorized>): Payment<Captured> {
// ... logic to capture the funds ...
console.log(`Capturing payment of $${payment.getAmount()}`);
return new Payment<Captured>(payment.amount);
}
Now, try to use these functions incorrectly. Just try.
const myNewPayment = new Payment<Created>(100); // OK
const authorizedPayment = authorize(myNewPayment); // OK
// @ts-expect-error - Argument of type 'Payment<Authorized>'
// is not assignable to parameter of type 'Payment<Created>'.
const whatIsThis = authorize(authorizedPayment);
// @ts-expect-error - Argument of type 'Payment<Created>'
// is not assignable to parameter of type 'Payment<Authorized>'.
const nope = capture(myNewPayment);
const capturedPayment = capture(authorizedPayment); // OK!
The TypeScript compiler will throw a tantrum, and you should thank it for doing so. You’ve just prevented a whole class of state-related bugs without writing a single if statement.
Adding State-Specific Methods
You can make this even more powerful by adding methods that only exist on certain states. TypeScript’s conditional types are perfect for this. Let’s add a refund method that only exists on a Payment<Captured>.
class Payment<T> {
constructor(public readonly amount: number) {}
getAmount(): number {
return this.amount;
}
// This method only exists if T is Captured
refund?(this: Payment<Captured>): string {
return `Refunding $${this.amount}`;
}
}
Now, the intelligence of your editor kicks in:
const createdPayment = new Payment<Created>(100);
createdPayment.refund?.(); // TypeScript allows this because of the optional chaining, but it's undefined at runtime.
const capturedPayment = capture(authorize(createdPayment));
capturedPayment.refund(); // OK! This exists and returns "Refunding $100"
The Big Caveat: It’s Only a Compile-Time Guard
Here’s the part where I have to be brutally honest with you. This is all a magnificent compile-time illusion. It’s a lie we tell the type checker to get better safety. At runtime, a Payment<Created> and a Payment<Captured> are identical. They are both just objects with an amount: number property.
This means you can absolutely circumvent the entire system with a type assertion (as any), which is like telling the compiler “shut up, I know what I’m doing” when you almost certainly do not. You can also get into trouble with serialization/deserialization. If you JSON.stringify a Payment<Captured> and then JSON.parse it, you’ll get a plain object back, and all your beautiful type information is gone. You’ll need a “guard” function to validate the parsed data and reassert the correct phantom type.
This pattern is best for tracking state within the boundaries of your own application, where you have control over the entire lifecycle of the object. It’s less suited for data that needs to cross a network boundary or be stored in a database without a careful rebuilding process.
Used within its lane, however, it’s an incredibly powerful tool for making invalid states unrepresentable in your code. And that, frankly, is one of the best feelings in software development.