TypeScript's value proposition is not 'type annotations on JavaScript'. It is a type system powerful enough to encode business rules, impossible states, and domain invariants directly in your code — making illegal states unrepresentable at compile time rather than catching them as runtime errors in production. Most TypeScript codebases use 15% of this capability. Here are the patterns that unlock the rest.
Discriminated Unions for State Machines
Every fetch operation, every form, every multi-step process is a state machine. In most codebases, this is represented as a tangle of optional fields: { data?: T, error?: Error, isLoading: boolean }. The problem is that this type permits impossible states: isLoading: true with data present, or both data and error simultaneously set.
Discriminated unions make impossible states literally unrepresentable. The compiler enforces that you handle every valid state explicitly in a switch statement, and any unreachable case becomes a compile error rather than a silent runtime bug.
// ❌ Allows impossible states (data + error simultaneously)
type FetchState<T> = {
data?: T;
error?: Error;
isLoading: boolean;
};
// ✅ Discriminated union — every state is explicit and exclusive
type FetchState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error; retryCount: number };
// The compiler enforces exhaustive handling
function renderFetch<T>(state: FetchState<T>) {
switch (state.status) {
case 'idle': return <Idle />;
case 'loading': return <Spinner />;
case 'success': return <Data data={state.data} />;
case 'error': return <Error error={state.error} />;
// The compiler would warn if a case is missing
}
}Branded Types for Domain Invariants
Consider a function that takes a userId: string and an orderId: string. TypeScript permits passing them in the wrong order with no error. A UserId and an OrderId are different domain concepts with different validation rules — representing both as plain string is a type system failure.
Branded types (also called nominal types) encode domain identity at the type level. A UserId is a string that has passed through a validation function that returns a UserId-branded string. The compiler then prevents you from passing a plain string or an OrderId where a UserId is expected.
// Branded type pattern
type Brand<T, B extends string> = T & { __brand: B };
export type UserId = Brand<string, 'UserId'>;
export type OrderId = Brand<string, 'OrderId'>;
export type TenantId = Brand<string, 'TenantId'>;
// Smart constructors — the only way to create branded values
export function parseUserId(id: string): UserId {
if (!UUID_REGEX.test(id)) throw new Error(`Invalid UserId: ${id}`);
return id as UserId;
}
// Now this is a compile error — the domain is self-documenting
function getUser(userId: UserId, tenantId: TenantId): Promise<User> { ... }
const orderId = parseOrderId('ord_123');
getUser(orderId, tenantId); // ❌ Compile error: Argument of type 'OrderId' is not assignable to parameter of type 'UserId'The Opaque Result Type for Error Handling
Throwing exceptions for expected error conditions (invalid input, resource not found, permission denied) is a TypeScript anti-pattern because it moves error handling out of the type system. A function that throws cannot be forced to declare what it throws, and callers are not reminded by the compiler to handle the error path.
The Result type pattern (popularised by Rust) makes error handling explicit and compiler-enforced. A function that can fail returns Result<T, E> — either Ok(value) or Err(error) — and the caller must unwrap the result, handling both branches explicitly. No undefined 'did this function throw?' guessing.
Conclusion
The advanced TypeScript patterns — discriminated unions, branded types, opaque Result types, template literal types for API contracts — are not academic exercises. They encode business rules, domain invariants, and error contracts directly in the type system, where the compiler enforces them on every change. The payoff is a codebase where engineers can refactor with confidence, where regressions are caught at compile time, and where the business logic is readable from the types alone.
Key Takeaways
- Discriminated unions make impossible states unrepresentable at compile time
- Branded types encode domain identity — prevent OrderId from being misused as UserId
- The Result<T, E> type makes error handling explicit and compiler-enforced
- Template literal types can model API route contracts with full type safety
- Advanced TypeScript is not complexity — it is business rule documentation that the compiler verifies