Protocols—whether for WebSocket handshakes, payment flows, or multi-step wizards—are notoriously easy to misuse at runtime. A client sends a message out of order, a server responds with an unexpected state, and suddenly you're debugging a race condition that only happens in production. TypeScript's type system can enforce valid state transitions at compile time, eliminating entire categories of bugs before they reach production. This guide walks through building type-level state machines using discriminated unions, conditional types, and template literal types, comparing three approaches and their trade-offs.
Who Needs Type-Level State Machines and When
If you've ever written a WebSocket client that sends a message before the connection is open, or a payment integration that calls 'refund' on a transaction that hasn't been authorized, you've felt the pain of implicit protocol state. Type-level state machines encode the allowed transitions directly into the type system, so the compiler rejects illegal operations before a single line of runtime code executes.
This technique is not for every project. Small scripts with one or two states rarely justify the overhead. But for teams building libraries, SDKs, or core business logic where protocol violations are costly, the investment pays off quickly. Consider a real-time collaboration feature: users join, leave, send messages, and disconnect. Without state enforcement, you might accidentally broadcast to a disconnected user or allow duplicate join requests. A type-level machine catches those errors during development, not after a customer reports a bug.
We'll focus on three scenarios: network protocols (WebSocket, TCP handshake), multi-step forms (checkout wizards, onboarding flows), and API orchestration (OAuth2 authorization code flow). Each benefits from compile-time guarantees that the runtime sequence is valid. The key decision point is when the cost of a runtime error exceeds the cost of the type-level complexity. For most production services, that threshold is lower than you think.
Three Approaches to Encoding State Machines
There is no single 'best' way to model state machines in TypeScript. The right choice depends on your team's familiarity with advanced types, the complexity of your protocol, and how much you care about error message quality. We'll examine three common patterns: simple discriminated unions, generic state machine builders with mapped types, and phantom-type encodings for zero-runtime overhead.
Approach 1: Discriminated Unions with Exhaustive Checks
The simplest approach uses a discriminated union for states and a function that returns the next state. Each state is a type with a discriminant property (like 'kind'), and transitions are validated via switch statements with exhaustive checks. This pattern is easy to understand, works with any TypeScript version, and produces decent error messages when a transition is missing. However, it requires manual updates when adding new states or transitions, and it doesn't prevent calling invalid transitions at the type level—you still need runtime guards.
Approach 2: Generic State Machine Builder with Mapped Types
For more complex protocols, a generic builder can generate the state machine from a configuration object. Using mapped types and conditional types, you define a 'Transitions' type that maps each state to its allowed next states. A 'transition' function then accepts only valid state–event pairs. This reduces boilerplate and centralizes the state definition, but it requires deeper type-level programming and can produce cryptic error messages when types don't align. Libraries like 'xstate' use this pattern internally, though they add runtime overhead.
Approach 3: Phantom-Type Encodings for Zero Overhead
Phantom types use unused type parameters to encode state without affecting runtime behavior. Each state is a distinct type (like 'Open' or 'Closed'), and functions are overloaded to accept only specific state types. This approach has zero runtime cost—the types are erased during compilation—and provides the strongest guarantees. However, it's the most complex to set up, requires careful type inference, and can overwhelm developers unfamiliar with the pattern. It's best suited for performance-critical libraries or protocols where every nanosecond matters.
Criteria for Choosing Your Approach
When evaluating which state machine pattern to adopt, consider four dimensions: complexity of the protocol, team familiarity with advanced types, error message quality, and maintenance burden. Let's break each down.
Protocol Complexity
Simple linear protocols (like a one-way handshake with three states) work well with discriminated unions. Protocols with many states and branching transitions (like an OAuth2 flow with authorization, token exchange, refresh, and error states) benefit from a builder approach. Protocols with concurrent states or history (like a TCP connection with simultaneous open) may require phantom types or a dedicated library.
Team Familiarity
If your team is comfortable with conditional types and mapped types, the builder approach is a natural fit. If they struggle with generics, stick with discriminated unions and invest in good runtime validation. Phantom types are best reserved for teams that already use advanced type patterns in production.
Error Message Quality
Discriminated unions produce the clearest TypeScript errors: 'Type X is not assignable to type Y'. Builder and phantom types often produce errors like 'Type 'never' is not assignable to type 'Z'', which can be confusing. Consider wrapping complex types in helper functions that provide descriptive error messages using type assertions or branded types.
Maintenance Burden
Discriminated unions require updating both the state union and every switch statement when adding a new state. Builder approaches centralize the state definition but may require recompilation of dependent modules. Phantom types are the hardest to refactor because changing a state type can cascade through many function signatures. Evaluate how often your protocol changes—if it's stable, the upfront investment in a builder or phantom type pays off; if it's volatile, simpler is better.
Trade-offs in Practice: A Structured Comparison
To make the trade-offs concrete, let's compare the three approaches across several dimensions in a table. We'll use a simplified WebSocket protocol with states: 'Disconnected', 'Connecting', 'Connected', and 'Closing'. Each approach handles the same transitions: connect, disconnect, message, close.
| Dimension | Discriminated Union | Generic Builder | Phantom Type |
|---|---|---|---|
| Lines of type code | ~30 | ~60 | ~80 |
| Runtime overhead | None (types erased) | None (types erased) | None (types erased) |
| Error message clarity | High | Medium | Low |
| Ease of adding states | Manual updates | Config change | New type + overloads |
| Prevents illegal transitions at compile time | Partial (requires exhaustive check) | Full | Full |
| Learning curve | Low | Medium | High |
| Best for | Small, stable protocols | Medium, branching protocols | Performance-critical libraries |
The table shows that no approach dominates. Discriminated unions are the easiest to start with and maintain, but they don't prevent all illegal transitions at the type level—you still need runtime checks for events that don't correspond to the current state. Builder approaches offer stronger guarantees with moderate complexity, while phantom types provide the strongest guarantees at the cost of readability.
One often overlooked trade-off is how each approach interacts with runtime validation libraries like Zod. Discriminated unions integrate naturally with Zod's discriminated union, allowing you to parse incoming messages and narrow the state. Builder and phantom types require more manual mapping between runtime parsers and type-level states, which can introduce duplication. If your protocol involves untrusted input (e.g., WebSocket messages from clients), runtime validation is non-negotiable, and you should favor approaches that minimize the gap between runtime and compile-time representations.
Implementation Path After Choosing Your Approach
Once you've selected an approach, follow these steps to implement a type-level state machine for your protocol. We'll use a concrete example: modeling the OAuth2 authorization code flow with states 'Idle', 'AuthorizationPending', 'TokenRequested', 'TokenReceived', and 'Error'. The events are 'authorize', 'requestToken', 'receiveToken', 'error', and 'retry'.
Step 1: Define States and Events
Start by listing all possible states and events. For each state, determine which events are valid. In our OAuth2 flow, from 'Idle' you can only 'authorize'. From 'AuthorizationPending', you can 'requestToken' or 'error'. From 'TokenRequested', you can 'receiveToken' or 'error'. From 'TokenReceived', you can only 'retry' (to start over) or do nothing. From 'Error', you can 'retry' to go back to 'Idle'. Write these down as a transition table.
Step 2: Encode the Transition Table in Types
Using your chosen approach, encode the transition table. For a discriminated union, you'd create a type for each state and a function that takes the current state and an event, returning the next state. Use a switch statement with an exhaustive check (using 'never' in the default case) to ensure all transitions are handled. For a builder approach, define a 'Transitions' type that maps each state to a record of events and their resulting states. For phantom types, create distinct state types and overload the transition function for each valid pair.
Step 3: Add Runtime Guards
Even with compile-time guarantees, runtime input from external sources (like network messages) must be validated. Use a library like Zod to parse incoming events and ensure they match the expected shape. Then cast the parsed event to the appropriate type-level event. This two-step process—runtime validation followed by type narrowing—gives you both safety and ergonomics.
Step 4: Test with Exhaustive Property Tests
Write property-based tests that generate random sequences of events and verify that the state machine never enters an invalid state. Use a library like 'fast-check' to generate sequences of up to 10 events and assert that the 'transition' function never throws or returns an unexpected state. This catches edge cases where the type system might allow a transition that your runtime logic doesn't handle correctly.
Step 5: Integrate with Your Application
Finally, integrate the state machine into your application logic. For a WebSocket client, the state machine would manage the connection lifecycle: you'd call 'connect' only when in 'Disconnected' state, and the machine would transition to 'Connecting'. The runtime WebSocket events (onopen, onclose, onerror) would trigger the corresponding state transitions. This ensures that your application code never sends a message on a closed connection or attempts to reconnect while already connecting.
Risks of Getting It Wrong
Choosing the wrong approach or skipping the implementation steps can lead to several classes of bugs. The most common is the 'unhandled transition' bug: a state-event pair that is valid at runtime but not encoded in the type system, leading to a runtime error that could have been caught at compile time. This often happens when the transition table is incomplete or when the type system is too permissive (e.g., using 'any' for events).
State Explosion
Another risk is state explosion: adding too many states or events that make the machine unwieldy. For example, if you model every possible HTTP status code as a state, the transition table becomes enormous and hard to maintain. The solution is to group related states (e.g., all 4xx errors as 'ClientError') and only split when the behavior differs. A good rule of thumb is to keep the number of states under 10 for a single machine; beyond that, consider composing smaller machines.
Type Inference Failures
With builder and phantom types, type inference can fail in unexpected ways. For instance, if you use a generic function that infers the state from a parameter, the inference might widen to a union type, allowing illegal transitions. Mitigate this by using explicit type annotations at key boundaries (like the transition function's return type) and by writing small unit tests that verify the type system rejects illegal calls.
Runtime Validation Gaps
Perhaps the most dangerous risk is relying solely on type-level safety without runtime validation. TypeScript types are erased at runtime, so any input from external sources (API responses, user input, network messages) must be validated. A common mistake is to cast parsed JSON directly to a state type without checking its structure. Always validate runtime data with a schema validator, then map it to your type-level states. This ensures that even if the type system is bypassed (e.g., via 'as any'), the runtime behavior remains correct.
Frequently Asked Questions
Can I use type-level state machines with existing state management libraries like Redux or XState?
Yes, but the integration point matters. For Redux, you can encode the state machine in the reducer's type signature, ensuring that the reducer only accepts valid state–action pairs. For XState, the library already provides a runtime state machine, but you can layer type-level safety on top by defining the state schema as a type and using it to constrain the machine's configuration. The two approaches complement each other: XState handles complex behaviors like guards and actions, while type-level encoding catches configuration errors at compile time.
How do I handle asynchronous transitions, like waiting for a network response?
Asynchronous transitions are modeled as two separate events: a request event (e.g., 'sendRequest') that transitions to a 'Waiting' state, and a response event (e.g., 'receiveResponse') that transitions to the next state. The type system doesn't need to know about the async nature—it just sees two sequential transitions. The runtime code uses promises or callbacks to trigger the response event after the request completes. This keeps the type model simple while the runtime handles concurrency.
What about error recovery? Can the type system model retries?
Yes, by including error states and retry transitions. For example, from a 'RequestFailed' state, you can have a 'retry' event that goes back to 'SendingRequest'. You can also model exponential backoff by adding a counter as a type parameter (e.g., 'SendingRequest
Do type-level state machines affect bundle size?
No—TypeScript types are completely erased during compilation, so there is zero runtime overhead. The only cost is in compile time, which can increase for complex generic types. For most projects, the compile-time cost is negligible. If you're concerned, measure with 'tsc --diagnostics' and compare before and after adding the state machine types.
Recommendation Recap: Choose Based on Your Context
After evaluating the trade-offs, here are our concrete recommendations. For small, stable protocols with fewer than five states and linear transitions, start with discriminated unions and exhaustive checks. This gives you good safety with minimal complexity. For medium protocols with branching transitions (like OAuth2 or a checkout wizard), use a generic builder approach with mapped types. The upfront investment in a configuration object pays off as the protocol evolves. For performance-critical libraries or protocols with strict latency requirements, consider phantom types, but only if your team has experience with advanced type patterns.
Whichever approach you choose, always pair it with runtime validation for external input. Use Zod or io-ts to parse incoming events and narrow to the correct type-level state. Write property-based tests to catch edge cases in the transition logic. And document the transition table clearly—both in code comments and in a README—so future maintainers understand the protocol's constraints.
Finally, remember that type-level state machines are a tool, not a goal. If the type complexity slows down your team more than it prevents bugs, scale back. Start with a simple discriminated union for the critical paths, and only add builder or phantom types when you need stronger guarantees. The best state machine is the one that your team can understand and maintain over the long term.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!