36.3 Building a Simple Type-Level State Machine
Right, so you want to build a state machine. But not just any state machine—one that’s enforced at compile time. The kind where if you try to do something stupid, the compiler gives you a friendly, and by friendly I mean utterly incomprehensible, type error instead of letting your program explode at runtime. This is where type-level programming stops being academic and starts being your best friend.
We’re going to build a simple state machine for a network connection. It has three states: Disconnected, Connecting, and Connected. The rules are simple:
- You can only
connectfromDisconnected. - You can only
sendDatafromConnected. - You can
disconnectfrom eitherConnectingorConnected.
Trying to sendData while Disconnected should be a compile-time error. Let’s make that happen.
The Core Technique: Phantom Types and Parameterized Types
The trick is to parameterize our main type, let’s call it Connection, with a type parameter that represents its current state. This state type parameter is a phantom type—it exists only at the type level to enforce constraints and doesn’t exist at runtime. We’ll use empty classes (or better yet, literal string types) to represent our states.
// Define our states as literal string types
type Disconnected = 'Disconnected';
type Connecting = 'Connecting';
type Connected = 'Connected';
// Our main Connection class is generic over a State type
class Connection<State> {
// ...internal connection state would go here
// Constructor is private. We'll create a static method to start.
private constructor() {}
}
We’ve now got a Connection that is always tagged with its state. A Connection<'Disconnected'> is a fundamentally different type from a Connection<'Connected'>, and we can use that to our advantage.
Implementing the State-Transition Methods
Now, let’s add methods. But crucially, we’ll only add them to the Connection type when the state parameter allows it. We use method overrides and conditional types with this return types to guide the type inference.
class Connection<State> {
private constructor() {}
// The only way to start: create a Disconnected connection.
static create(): Connection<Disconnected> {
return new Connection<Disconnected>();
}
// 1. You can only connect from Disconnected
connect(this: Connection<Disconnected>): Connection<Connecting> {
console.log("Connecting...");
// ...actual connection logic would go here
return this as unknown as Connection<Connecting>; // More on this cast later
}
// 2. You can only sendData from Connected
sendData(this: Connection<Connected>, data: string) {
console.log(`Sending: ${data}`);
}
// 3. You can disconnect from Connecting OR Connected.
// We use a conditional type for the return.
disconnect(
this: Connection<Connecting | Connected>
): Connection<Disconnected> {
console.log("Disconnecting.");
return this as unknown as Connection<Disconnected>;
}
}
See what we did there? The this: Connection<SpecificState> parameter is the magic. It’s a fake parameter that tells TypeScript, “This method can only be called if this is contextually of this more specific type.” It doesn’t exist at runtime; it’s purely a type constraint.
Seeing It in Action (And Seeing It Fail Spectacularly)
Let’s run through the happy path. The type inference here is glorious.
// Start the journey
const conn = Connection.create(); // Type: Connection<'Disconnected'>
// This is the only valid next step
const connectingConn = conn.connect(); // Type: Connection<'Connecting'>
// Let's assume we have a method to complete the connection
// (We'd add a 'completeConnect' method constrained to Connecting state)
const connectedConn = completeConnect(connectingConn); // Type: Connection<'Connected'>
// Now we can send data!
connectedConn.sendData("ping"); // OK
// And we can disconnect back to the start
const disconnectedAgain = connectedConn.disconnect(); // Type: Connection<'Disconnected'>
Now, watch the compiler save you from yourself:
const conn = Connection.create();
conn.sendData("ping");
// Compiler Error:
// Property 'sendData' does not exist on type 'Connection<"Disconnected">'.
const connectedConn = completeConnect(conn.connect());
connectedConn.connect();
// Compiler Error:
// The 'this' context of type 'Connection<"Connected">' is not assignable to method's 'this' of type 'Connection<"Disconnected">'.
Is that beautiful? It’s beautiful. The program is fundamentally incapable of entering an invalid state.
The Dirty Secret: The Inevitable Cast
Did you spot the as unknown as Connection<Connecting> in the connect method? Here’s the rough edge. We’re lying to the type system.
At runtime, it’s the same object (this). Nothing about the actual JavaScript object has changed. We haven’t magically added a 'Connected' property to it. We, the programmers, know that after the connect method runs, the logical state of the object has changed, so we forcefully update its type.
This is the critical pact you make with type-level programming: You are responsible for ensuring the runtime value matches the type-level assertion. The type system trusts you. Don’t break that trust. The cast is a necessary escape hatch, but it must be used with extreme discipline. The alternative is a much more complex abstraction that creates a new object for every state transition, which is often overkill.
Best Practices and Pitfalls
- Keep State Types Simple: Use literal types (
'A','B') or unique symbols. Avoid complex objects as state type parameters; it makes inference messy. - Name Your States Well:
Connection<State>is okay.Connection<CurrentConnectionState>is clearer. Your colleagues will thank you. - This is a Leaky Abstraction: Anyone with access to the object can still do an unsafe cast
(conn as Connection<'Connected'>).sendData(). This pattern protects against accidental misuse, not malicious or determined misuse. It’s a seatbelt, not a prison. - Combinatorial Explosion: For a complex state machine with many states and transitions, the number of method overrides can get… silly. This is where you might want to explore techniques like using a type-level
iforswitch(via conditional types) to map a transition table, but that’s a topic for another day.
This pattern is incredibly powerful for API design. It makes illegal states unrepresentable, turning runtime bugs into compile-time errors. It’s a bit of upfront complexity for a world of reduced debugging pain. And honestly, watching the IDE’s IntelliSense automatically grey out invalid methods is a feeling of power that never gets old.