Every API client has a state machine hiding inside it. Handshake first. Authenticate second. Send data third. Close last. Break that order and you get runtime errors, confused servers, or worse, silent data corruption.
Most teams encode these rules with runtime checks. if (!connected) throw new Error(...). That works until someone forgets the check, or a refactor introduces a new code path that skips it. By the time you notice, the bug is in production.
TypeScript’s type system can eliminate an entire class of these bugs. Not with clever lint rules, but by making illegal states literally unrepresentable.
Your API client has a hidden state machine
Session types are a type-system technique for encoding the valid sequence of operations in a protocol. Instead of tracking state with a string field at runtime, you track it in the type parameter. A Channel<'idle'> can only call connect(). A Channel<'connected'> can only call send() and close(). The compiler rejects everything else.
This isn’t academic. If you’ve ever used a database transaction, a WebSocket connection, or an OAuth flow, you’ve manually enforced a session protocol. Session types just move that enforcement from runtime to compile time.
Phantom types make illegal transitions unrepresentable
Here’s a typical state machine in TypeScript:
class Channel {
private state: 'idle' | 'connected' | 'closed' = 'idle';
connect() {
if (this.state !== 'idle') throw new Error('Already connected');
this.state = 'connected';
}
send(msg: string) {
if (this.state !== 'connected') throw new Error('Not connected');
// ...
}
close() {
this.state = 'closed';
}
}
This is fine until it isn’t. The send() method throws at runtime if you call it at the wrong time. Testing might catch it. Might not. Refactoring might introduce a new send() call after close() that no one notices. The compiler has no opinion.
TypeScript uses structural typing. Two classes with the same shape are interchangeable, even if their type parameters differ. To make Channel<'idle'> and Channel<'connected'> incompatible, the type parameter must appear in the structure itself.
The standard pattern is a private phantom field:
class Channel<State extends string> {
private __state!: State;
private constructor() {}
static create(): Channel<'idle'> {
return new Channel();
}
connect(this: Channel<'idle'>): Channel<'connected'> {
return new Channel();
}
send(this: Channel<'connected'>, msg: string): Channel<'connected'> {
console.log(msg);
return new Channel();
}
close(this: Channel<'connected'>): Channel<'closed'> {
return new Channel();
}
}
The __state field never gets a value. It exists only to bind the type parameter to the class structure. Because it’s private, external code can’t fabricate a Channel<'connected'>. Because it has type State, Channel<'idle'> and Channel<'connected'> are structurally different types.
The this parameter on each method restricts which states can call it. TypeScript checks this compatibility at the call site, not just inside the method body.
Now the illegal calls are compile-time errors:
const ch = Channel.create();
ch.send('hello'); // Error: 'Channel<"idle">' is not assignable to 'Channel<"connected">'
This is the part that trips people up. The this parameter looks like a runtime annotation. It isn’t. TypeScript uses it purely for type-checking the receiver. If the receiver’s type doesn’t match, the compiler rejects the call entirely.
Building a type-safe file upload protocol
The toy Channel shows the pattern. Here’s something closer to a real protocol: a file upload client with authentication and checksum verification.
type UploadState = 'idle' | 'ready' | 'uploading' | 'verifying' | 'done';
class Uploader<State extends UploadState> {
private __state!: State;
private file: string;
private constructor(file: string) {
this.file = file;
}
static forFile(file: string): Uploader<'idle'> {
return new Uploader(file);
}
authenticate(
this: Uploader<'idle'>,
token: string
): Uploader<'ready'> {
// verify token...
return new Uploader(this.file);
}
uploadChunk(
this: Uploader<'ready' | 'uploading'>,
data: Uint8Array
): Uploader<'uploading'> {
// stream bytes...
return new Uploader(this.file);
}
finalize(
this: Uploader<'uploading'>,
checksum: string
): Uploader<'verifying'> {
// start verification...
return new Uploader(this.file);
}
verify(
this: Uploader<'verifying'>,
serverChecksum: string
): Uploader<'done'> {
// compare checksums...
return new Uploader(this.file);
}
}
Notice uploadChunk accepts Uploader<'ready' | 'uploading'> as its this type. Union types let you express methods that are valid from multiple states. The method returns Uploader<'uploading'>, so after the first call you’re locked into the uploading state until you finalize.
const chunk1 = new Uint8Array([0x01, 0x02]);
const chunk2 = new Uint8Array([0x03, 0x04]);
const upload = Uploader.forFile('report.pdf')
.authenticate('token-123')
.uploadChunk(chunk1)
.uploadChunk(chunk2)
.finalize('abc123')
.verify('abc123');
// upload is now Uploader<'done'>
Try to call authenticate() after uploadChunk(), or uploadChunk() after verify(), and TypeScript errors immediately.
Where the pattern gets awkward
The pattern isn’t free. The biggest pain point is that every state transition constructs a new object. In our examples we create a new Uploader even when the underlying state hasn’t changed, like uploadChunk returning Uploader<'uploading'> from Uploader<'uploading'>. For complex objects with lots of state, that’s wasteful.
You can optimize by passing state through instead of reconstructing, but the types get noisier. A second issue: TypeScript’s error messages for this-parameter mismatches are cryptic. “The ‘this’ context of type ‘X’ is not assignable to method’s ‘this’ of type ‘Y’” is accurate, but not immediately obvious to someone reading the code for the first time. Good naming and comments help, but the developer experience isn’t perfect.
Third, this pattern doesn’t compose well with async code. An await in the middle of a chain breaks the fluent interface, and you need to store the intermediate typed result in a variable. That’s not a dealbreaker, but it does mean the pattern works best for synchronous or carefully structured async protocols.
Tagged unions are the pragmatic middle ground
If the full phantom-type pattern feels heavy, tagged unions with exhaustive switch statements are a pragmatic alternative. You still get compile-time safety, but at the value level rather than the type level:
type UploadState =
| { tag: 'idle'; file: string }
| { tag: 'ready'; file: string; token: string }
| { tag: 'uploading'; file: string; sent: number }
| { tag: 'done'; file: string };
function authenticate(
state: Extract<UploadState, { tag: 'idle' }>,
token: string
): UploadState {
return { tag: 'ready', file: state.file, token };
}
This is more idiomatic TypeScript and easier for most teams to maintain. The trade-off is that you can’t prevent someone from passing the wrong state variant to a function at the type level without the same this-parameter tricks. The compiler catches it inside the function, but the call site isn’t restricted.
Reach for phantom types when violations cause real harm
Use phantom types when the protocol is complex enough that violating it causes real harm, and when the API surface is small enough that you control every transition. Database drivers, network protocol implementations, and stateful SDKs are good candidates.
Don’t use them for simple CRUD APIs or anything where the extra type complexity costs more than the occasional runtime check.
The next time you’re writing if (state !== 'connected') throw new Error(...), ask whether that check belongs in your code or in your types. TypeScript’s this parameters and phantom fields give you a way to push protocol enforcement upstream. The bugs you catch won’t be the dramatic ones. They’ll be the quiet mistakes that slip through code review and only show up when a customer hits an edge case you didn’t test.
Those are exactly the bugs worth eliminating.