Type-State Pattern (Phantom Types)
Encode object lifecycle states into the type system so that calling the wrong method in the wrong state is a compile error, not a runtime exception.
Overview
Key Points
- •Phantom state type: `class Handle<State extends string>` where State is never used at runtime
- •State-constrained methods: `read(this: Handle<"Open">): string` - only callable when State is "Open"
- •State transitions: `open(): Handle<"Open">` returns a new type with updated state
- •Impossible transitions become compile errors - no runtime checks needed
- •Works best with a small, finite set of states
- •Can be combined with discriminated unions for state with payload (e.g., `Loading | Success<T> | Error`)
- •Used in: connection lifecycle, authentication flow, wizard steps, workflow engines
Examples
File handle with type-safe open/close lifecycle
// Declare valid states as string literals
declare const __state: unique symbol;
type State<S extends string> = { readonly [__state]: S };
class FileHandle<S extends string = 'Closed'> {
private constructor(private path: string) {}
static create(path: string): FileHandle<'Closed'> {
return new FileHandle(path);
}
// Only callable when Closed - returns Open handle
open(this: FileHandle<'Closed'>): FileHandle<'Open'> {
console.log(`Opening ${this.path}`);
return this as unknown as FileHandle<'Open'>;
}
// Only callable when Open
read(this: FileHandle<'Open'>): string {
return `Content of ${this.path}`;
}
write(this: FileHandle<'Open'>, data: string): void {
console.log(`Writing to ${this.path}: ${data}`);
}
// Only callable when Open - returns Closed handle
close(this: FileHandle<'Open'>): FileHandle<'Closed'> {
console.log(`Closing ${this.path}`);
return this as unknown as FileHandle<'Closed'>;
}
}
const file = FileHandle.create('config.json');
// Must open first
const opened = file.open();
const content = opened.read();
console.log(content);
opened.write('new data');
const closed = opened.close();
// These are all compile errors:
// file.read(); // Error - file is Closed
// file.write('data'); // Error - file is Closed
// opened.open(); // Error - already Open
// closed.read(); // Error - closed handleAuthentication flow with type-state
class AuthSession<State extends 'Anonymous' | 'Authenticated' | 'Verified' = 'Anonymous'> {
private _user: { id: string; email: string } | null = null;
private _token: string | null = null;
static create(): AuthSession<'Anonymous'> {
return new AuthSession();
}
login(
this: AuthSession<'Anonymous'>,
email: string,
password: string
): AuthSession<'Authenticated'> {
// Simulate login
this._user = { id: '1', email };
this._token = 'jwt_token_here';
console.log(`Logged in as ${email}`);
return this as unknown as AuthSession<'Authenticated'>;
}
// 2FA or email verification step
verify(
this: AuthSession<'Authenticated'>,
code: string
): AuthSession<'Verified'> {
console.log(`Verified with code: ${code}`);
return this as unknown as AuthSession<'Verified'>;
}
// Sensitive operations only available after full verification
getSecretData(this: AuthSession<'Verified'>): string {
return `Sensitive data for user ${this._user?.id}`;
}
getUserInfo(this: AuthSession<'Authenticated' | 'Verified'>) {
return this._user;
}
logout(
this: AuthSession<'Authenticated' | 'Verified'>
): AuthSession<'Anonymous'> {
this._user = null;
this._token = null;
return this as unknown as AuthSession<'Anonymous'>;
}
}
const session = AuthSession.create();
const loggedIn = session.login('alice@example.com', 'password');
const verified = loggedIn.verify('123456');
console.log(verified.getSecretData());
console.log(verified.getUserInfo());
// session.getSecretData(); // Error - not authenticated
// loggedIn.getSecretData(); // Error - not verified (need 2FA)Generic state machine with type-state transitions
// Encode valid transitions in the type system
type Transitions = {
Draft: 'Pending';
Pending: 'Approved' | 'Rejected';
Approved: 'Shipped';
Rejected: never;
Shipped: never;
};
type OrderState = keyof Transitions;
class Order<S extends OrderState = 'Draft'> {
private constructor(
public readonly id: string,
public readonly status: S
) {}
static create(id: string): Order<'Draft'> {
return new Order(id, 'Draft');
}
transition<Next extends Transitions[S]>(
nextState: Next
): Order<Next> {
console.log(`Order ${this.id}: ${this.status} -> ${String(nextState)}`);
return new Order(this.id, nextState) as Order<Next>;
}
}
const order = Order.create('ORD-001');
const pending = order.transition('Pending');
const approved = pending.transition('Approved');
const shipped = approved.transition('Shipped');
console.log(shipped.status); // "Shipped"
// These are compile errors - invalid transitions:
// order.transition('Approved'); // Error - Draft can only go to Pending
// approved.transition('Pending'); // Error - Approved can only go to Shipped
// shipped.transition('Pending'); // Error - Shipped is a terminal state (never)Common Mistakes
- Returning `this` instead of `this as unknown as NewState` from transition methods - TypeScript needs the cast to accept the new phantom state
- Making transitions modify `this` in place: type-state works best when transitions return NEW instances (or cast the same instance to a new type) - mutating state while keeping the old type reference alive leads to inconsistency
- Over-applying type-state to every object: use it only when wrong-state-method calls are a real production risk. For internal implementation details, rely on runtime checks and clear error messages instead
Note
The type-state pattern shines for **resource lifecycle management** (open/close, connect/disconnect), **multi-step workflows** (wizard steps, payment flows), and **capability-based security** (unauthenticated/authenticated/admin). If you find yourself writing many `if (!this.isOpen) throw new Error(...)` checks, type-state may be a better fit.
Exercises
Write TypeScript code to solve each exercise. Click Run to compile and execute it right here, then mark as done.
Completed: 0 / 1 exercises
Knowledge Check
Question 1 of 4