TypeScript Design Patterns
TypeScript design patterns that use the type system to enforce correctness at compile time — the builder pattern with required-field tracking, type-safe state machines and event emitters, dependency injection, plugin systems, recursive/deep-readonly utility types, type-safe module encapsulation, advanced generic constraints, and typed API clients. Use when structuring a class or module so invalid usage fails to compile rather than throwing at runtime, e.g. "how do I stop `.build()` being called before required fields are set" or "how do I type an event emitter so payloads match their event name".
npx skills add github:pantheon-org/tekhne --skill development--typescript-advanced--design-patternsStructural patterns that push correctness into the type system: a class or module shaped so the compiler rejects a misuse the runtime would otherwise have to catch.
Mindset
A pattern here earns its place only if it converts a runtime failure into a compile error. Before reaching for one, ask what invalid usage it should make impossible to write, not just cleaner to read — a builder that still lets .build() run with missing fields is a fluent API, not a type-safe one. Prefer composing the type system’s own primitives (discriminated unions, conditional types, mapped types) over hand-rolled runtime checks wherever the invalid state can be excluded at the type level instead of merely caught. Treat generics as free specificity, not decoration: a generic parameter with no constraint is barely more useful than any, and a pattern that leaves one unconstrained has not finished its job.
When to Apply
Use this skill when:
- Enforcing required fields at compile time with the builder pattern
- Modeling application or connection state as a type-safe state machine
- Typing an event emitter so payloads are checked against their event name
- Wiring dependency injection with compile-time-verified dependencies
- Building an extensible plugin system with typed hooks
- Writing recursive utility types (
DeepReadonly,DeepPartial, tree structures) - Encapsulating a module’s internals behind a type-safe public surface
- Building a typed API client from a central route configuration
- Validating form fields (sync and async) with type-safe, cross-field-aware validators
- Applying advanced generic constraints for flexible, reusable code
Use When
- “How do I stop
.build()from compiling until every required field is set?” - “How do I type a plugin system so each plugin’s hooks are checked?”
- “How do I model a wizard/form flow so an invalid step transition doesn’t compile?”
- “How do I make a deeply nested config object read-only at every level?”
Scope
In Scope
- Structural design patterns implemented with TypeScript’s type system (builder, state machine, event emitter, DI, plugin system, module encapsulation, API client).
- Recursive and deep utility types (
DeepReadonly,DeepPartial, tree/path types). - Advanced generic constraint patterns.
Out of Scope
- Runtime type guards and narrowing mechanics that these patterns call into — see the sibling
typescript-type-guardsskill. - The underlying conditional/mapped/union type mechanics — see the sibling
typescript-type-systemskill. - Built-in utility types (
Partial,Pick,ReturnType) — see the siblingtypescript-utility-typesskill.
When NOT to Use
Do not use this skill to look up how a conditional type, mapped type, or discriminated union actually works at the type level — the pattern files assume that mechanic and build on top of it. Do not use it as a substitute for tsc --noEmit — a pattern that looks type-safe in a reference file still needs the compiler to confirm it holds for your actual types.
Quick Commands
Type Check
npx tsc --noEmit
Anti-Patterns
NEVER let a builder’s .build() compile with missing required fields
WHY: a builder pattern’s entire value is catching an incomplete construction at compile time; if .build() accepts an incomplete state, it is no safer than a plain object literal.
BAD:
class UserBuilder {
private name?: string;
setName(name: string) { this.name = name; return this; }
build() { return { name: this.name }; } // name may be undefined — compiles anyway
}
GOOD:
class UserBuilder<HasName extends boolean = false> {
private name?: string;
setName(name: string): UserBuilder<true> {
this.name = name;
return this as UserBuilder<true>;
}
build(this: UserBuilder<true>): { name: string } {
return { name: this.name! };
}
}
NEVER leave a generic type parameter unconstrained when the pattern relies on it having a shape
WHY: an unconstrained <T> gives the compiler nothing to check, so the pattern degrades to any the moment a caller passes something unexpected.
BAD:
function createInstance<T>(ctor: T, ...args: any[]) {
return new (ctor as any)(...args);
}
GOOD:
function createInstance<T extends new (...args: any[]) => any>(
ctor: T,
...args: ConstructorParameters<T>
): InstanceType<T> {
return new ctor(...args);
}
NEVER type an event emitter’s emit/on with a loose string and any[]
WHY: the whole point of a typed event emitter is that an event name and its payload are checked together; emit(event: string, ...args: any[]) throws that guarantee away.
BAD:
class EventEmitter {
emit(event: string, ...args: any[]): void { /* ... */ }
on(event: string, handler: (...args: any[]) => void): void { /* ... */ }
}
GOOD:
class EventEmitter<T extends Record<string, (...args: any[]) => void>> {
emit<K extends keyof T>(event: K, ...args: Parameters<T[K]>): void { /* ... */ }
on<K extends keyof T>(event: K, handler: T[K]): void { /* ... */ }
}
References
| File | Covers |
|---|---|
references/builder.md |
Compile-time enforcement of required fields in the builder pattern |
references/state-machine.md |
Discriminated-union state machines with compile-time transition validation |
references/event-emitter.md |
Type-safe event names and payloads |
references/dependency-injection.md |
Compile-time-verified dependency wiring |
references/plugin-system.md |
Extensible, typed plugin architectures |
references/deep-readonly.md |
DeepReadonly, DeepPartial, DeepRequired, DeepMutable, runtime deepFreeze |
references/recursive-types.md |
Self-referential types: JSON values, path types, tree structures |
references/type-safe-module.md |
Encapsulating module internals behind a typed public surface |
references/api-client.md |
Typed API clients built from a central route configuration |
references/form-validation.md |
Type-safe, cross-field-aware form validation (sync and async validators) |
references/branded-types.md |
Nominal typing with Zod-based validated brands and domain-primitive use cases |
references/advanced-generics.md |
Complex, composable generic constraint patterns |
Explicit NEVER/WHY/consequence triplets with side-by-side bad/good examples. Generic warnings score low — concrete failure modes with real impact (security, correctness, performance) score high.
Runtime validation via eval scenarios proving the skill actually changes agent behaviour. Requires evals/ directory, instructions.json, ≥3 scenarios, and ≥80% instruction coverage in summary.json.
Appropriate rigidity for the skill type: strict rules for critical/security skills, balanced steps for process skills, flexible options for tool skills. Mismatched calibration (too rigid or too loose) reduces score.
Expert-only knowledge the AI couldn't derive on its own — domain-specific patterns, production gotchas, decision frameworks, and non-obvious tool behaviour. High scores mean <5% redundancy with what the model already knows.
A clear philosophy plus numbered step-by-step workflows with explicit entry/exit points. Should answer: what is the core principle, how to execute it, and when (and when not) to apply it.
Rich activation keywords and trigger scenarios in the description so the skill fires when needed. Best descriptions read like an exhaustive list of the tasks and concepts that should invoke this skill.
Copy-paste-ready code, complete runnable examples, real file paths and commands. Pseudocode and abstract descriptions score low — concrete, executable content scores high.
SKILL.md as a <100-line navigation hub pointing to focused references/ files. Content should be lazy-loaded on demand, not front-loaded. Includes actionable 'When to Use' conditions per reference.
Proper frontmatter, single-task focus, activation keywords, and cross-harness portability. Skills must work across 40+ agentic harnesses without hardcoded paths or agent-specific references.
No eval scenarios available.
Advanced Generics Patterns
Master complex generic type patterns for flexible, reusable code.
Generic Constraints with keyof
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: 'Alice', age: 30 };
const name = getProperty(user, 'name'); // string
const age = getProperty(user, 'age'); // number
// getProperty(user, 'invalid'); // Error
Multiple Type Parameters with Constraints
function merge<T extends object, U extends object>(obj1: T, obj2: U): T & U {
return { ...obj1, ...obj2 };
}
const result = merge(
{ name: 'Alice' },
{ age: 30 }
); // { name: string; age: number }
Conditional Type Parameters
type ApiResponse<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };
function handleResponse<T, E = Error>(
response: ApiResponse<T, E>
): T {
if (response.success) {
return response.data;
}
throw response.error;
}
Generic Type Inference
function createPair<T, U>(first: T, second: U): [T, U] {
return [first, second];
}
// TypeScript infers types
const pair1 = createPair('hello', 42); // [string, number]
const pair2 = createPair(true, { x: 10 }); // [boolean, { x: number }]
Generic Factories
interface Factory<T> {
create(...args: any[]): T;
}
class UserFactory implements Factory<User> {
create(name: string, email: string): User {
return { id: generateId(), name, email };
}
}
class ProductFactory implements Factory<Product> {
create(name: string, price: number): Product {
return { id: generateId(), name, price };
}
}
function register<T>(factory: Factory<T>): void {
// Register factory
}
Higher-Kinded Types Simulation
interface HKT {
readonly _URI?: unknown;
readonly _A?: unknown;
}
interface URItoKind<A> {
Option: Option<A>;
Either: Either<Error, A>;
Array: Array<A>;
}
type Kind<URI extends keyof URItoKind<any>, A> = URItoKind<A>[URI];
// Functor interface
interface Functor<F extends keyof URItoKind<any>> {
map<A, B>(fa: Kind<F, A>, f: (a: A) => B): Kind<F, B>;
}
// Option functor
type Option<A> = { _tag: 'Some'; value: A } | { _tag: 'None' };
const OptionFunctor: Functor<'Option'> = {
map(fa, f) {
return fa._tag === 'Some'
? { _tag: 'Some', value: f(fa.value) }
: { _tag: 'None' };
}
};
Variadic Generic Functions
function compose<A, B, C>(
f: (b: B) => C,
g: (a: A) => B
): (a: A) => C {
return (a) => f(g(a));
}
const addOne = (n: number) => n + 1;
const double = (n: number) => n * 2;
const addOneAndDouble = compose(double, addOne);
addOneAndDouble(5); // 12
Generic Builder Pattern
class QueryBuilder<T, Selected extends keyof T = never> {
private selectedFields: Set<keyof T> = new Set();
private whereClause?: string;
select<K extends keyof T>(
...fields: K[]
): QueryBuilder<T, Selected | K> {
fields.forEach(f => this.selectedFields.add(f));
return this as any;
}
where(clause: string): this {
this.whereClause = clause;
return this;
}
build(): Pick<T, Selected> {
// Build query
return {} as Pick<T, Selected>;
}
}
interface User {
id: string;
name: string;
email: string;
age: number;
}
const query = new QueryBuilder<User>()
.select('name', 'email')
.where('age > 18')
.build(); // { name: string; email: string }
Recursive Generic Constraints
type Flatten<T> = T extends Array<infer U>
? U extends Array<any>
? Flatten<U>
: U
: T;
type Result1 = Flatten<number>; // number
type Result2 = Flatten<number[]>; // number
type Result3 = Flatten<number[][]>; // number
type Result4 = Flatten<number[][][]>; // number
Generic Type Guards
function isArrayOf<T>(
arr: unknown,
guard: (item: unknown) => item is T
): arr is T[] {
return Array.isArray(arr) && arr.every(guard);
}
function isString(value: unknown): value is string {
return typeof value === 'string';
}
function isNumber(value: unknown): value is number {
return typeof value === 'number';
}
const data: unknown = ['a', 'b', 'c'];
if (isArrayOf(data, isString)) {
data.forEach(s => s.toUpperCase()); // OK, data is string[]
}
Best Practices
- Infer when possible: Let TypeScript infer generics
- Constrain generics: Use extends for better type safety
- Default type parameters: Provide sensible defaults
- Avoid over-generalization: Don’t make everything generic
- Name meaningfully: Use descriptive type parameter names
- Document constraints: Explain complex generic constraints
Type-Safe API Client
Building API clients with route configuration and full type safety.
Basic API Client
type Routes = {
'/users': {
GET: { response: User[] };
POST: { body: CreateUserDTO; response: User };
};
'/users/:id': {
GET: { response: User };
PUT: { body: UpdateUserDTO; response: User };
DELETE: { response: void };
};
'/posts': {
GET: { query: { page: number; limit: number }; response: Post[] };
};
};
class ApiClient<T extends Record<string, Record<string, unknown>>> {
constructor(private baseUrl: string) {}
async request<
Path extends keyof T,
Method extends keyof T[Path]
>(
path: Path,
method: Method,
config?: T[Path][Method] extends { body: infer B } ? { body: B } : {}
& T[Path][Method] extends { query: infer Q } ? { query: Q } : {}
): Promise<T[Path][Method] extends { response: infer R } ? R : never> {
const url = new URL(path as string, this.baseUrl);
if (config && 'query' in config) {
Object.entries(config.query as Record<string, unknown>).forEach(([key, value]) => {
url.searchParams.append(key, String(value));
});
}
const response = await fetch(url.toString(), {
method: method as string,
headers: {
'Content-Type': 'application/json',
},
body: config && 'body' in config
? JSON.stringify(config.body)
: undefined,
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
}
// Usage with full type safety
const client = new ApiClient<Routes>('https://api.example.com');
// GET /users - no config needed
const users = await client.request('/users', 'GET');
// users is typed as User[]
// POST /users - requires body
const newUser = await client.request('/users', 'POST', {
body: { name: 'John', email: 'john@example.com' }
});
// newUser is typed as User
// GET /posts - requires query params
const posts = await client.request('/posts', 'GET', {
query: { page: 1, limit: 10 }
});
// posts is typed as Post[]
With Path Parameters
type PathParams<Path extends string> =
Path extends `${infer _Start}:${infer Param}/${infer Rest}`
? { [K in Param | keyof PathParams<`/${Rest}`>]: string }
: Path extends `${infer _Start}:${infer Param}`
? { [K in Param]: string }
: {};
class ApiClientWithParams<T extends Record<string, Record<string, unknown>>> {
async request<
Path extends keyof T & string,
Method extends keyof T[Path]
>(
path: Path,
method: Method,
config: PathParams<Path> extends Record<string, never>
? T[Path][Method] extends { body: infer B } ? { body: B } : {}
: { params: PathParams<Path> }
& (T[Path][Method] extends { body: infer B } ? { body: B } : {})
): Promise<T[Path][Method] extends { response: infer R } ? R : never> {
let url = path as string;
if ('params' in config) {
Object.entries(config.params as Record<string, string>).forEach(([key, value]) => {
url = url.replace(`:${key}`, value);
});
}
// ... rest of implementation
}
}
// Usage
const user = await client.request('/users/:id', 'GET', {
params: { id: '123' }
});
With Response Validation
import { z } from 'zod';
type ValidatedRoutes = {
'/users': {
GET: {
response: User[];
schema: z.ZodType<User[]>;
};
};
};
class ValidatedApiClient<T extends Record<string, Record<string, unknown>>> {
async request<
Path extends keyof T,
Method extends keyof T[Path]
>(
path: Path,
method: Method,
schema: T[Path][Method] extends { schema: infer S } ? S : never
): Promise<T[Path][Method] extends { response: infer R } ? R : never> {
const response = await fetch(/* ... */);
const data = await response.json();
// Runtime validation
return (schema as z.ZodType).parse(data);
}
}
Best Practices
- Define Route Types: Central route configuration with all endpoints
- Type Safety: Enforce correct method, body, and query params
- Path Parameters: Extract and validate path parameters from route strings
- Response Validation: Use Zod for runtime validation of responses
- Error Handling: Provide typed error responses
See Also
builder.mdin this skill — building typed request/response configs incrementallybasic-guards.mdandgeneric-guards.mdin the siblingtypescript-type-guardsskill — writing the type predicates used to validate responsesruntime-validation.mdin the siblingtypescript-practicesskill — Zod-based validation for responses
Branded Types (Nominal Typing)
Create distinct types from primitives to prevent mixing incompatible values.
Basic Branded Type
type Brand<K, T> = K & { __brand: T };
type UserId = Brand<string, 'UserId'>;
type ProductId = Brand<string, 'ProductId'>;
// Factory functions
const createUserId = (id: string): UserId => id as UserId;
const createProductId = (id: string): ProductId => id as ProductId;
const userId = createUserId('user-123');
const productId = createProductId('prod-456');
// Type error: can't mix branded types
// getUser(productId); // Error!
function getUser(id: UserId) {
return { id, name: 'Alice' };
}
Validated Branded Types
type Email = Brand<string, 'Email'>;
type PhoneNumber = Brand<string, 'PhoneNumber'>;
const createEmail = (value: string): Email => {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
throw new Error('Invalid email');
}
return value as Email;
};
const createPhoneNumber = (value: string): PhoneNumber => {
if (!/^\+?[\d\s-()]+$/.test(value)) {
throw new Error('Invalid phone number');
}
return value as PhoneNumber;
};
// Usage
const email = createEmail('user@example.com'); // OK
// const invalid = createEmail('not-an-email'); // Throws
Numeric Branded Types
type PositiveInt = Brand<number, 'PositiveInt'>;
type Percentage = Brand<number, 'Percentage'>;
const createPositiveInt = (n: number): PositiveInt => {
if (!Number.isInteger(n) || n <= 0) {
throw new Error('Must be a positive integer');
}
return n as PositiveInt;
};
const createPercentage = (n: number): Percentage => {
if (n < 0 || n > 100) {
throw new Error('Must be between 0 and 100');
}
return n as Percentage;
};
Branded Types with Zod
import { z } from 'zod';
const UserIdSchema = z.string().uuid().brand('UserId');
type UserId = z.infer<typeof UserIdSchema>;
const EmailSchema = z.string().email().brand('Email');
type Email = z.infer<typeof EmailSchema>;
// Validation
const userId = UserIdSchema.parse('550e8400-e29b-41d4-a716-446655440000');
const email = EmailSchema.parse('user@example.com');
Use Cases
- Prevent ID mixing: UserId vs ProductId
- Domain constraints: PositiveInt, NonEmptyString
- Validated formats: Email, URL, PhoneNumber
- Units: Meters, Seconds, Dollars
- Security: SanitizedHtml, HashedPassword
Builder Pattern with Type Safety
Implementing the Builder pattern with compile-time enforcement of required fields.
Basic Builder
interface User {
id: string;
name: string;
email: string;
age?: number;
role?: 'admin' | 'user';
}
class UserBuilder {
private user: Partial<User> = {};
setId(id: string): this {
this.user.id = id;
return this;
}
setName(name: string): this {
this.user.name = name;
return this;
}
setEmail(email: string): this {
this.user.email = email;
return this;
}
setAge(age: number): this {
this.user.age = age;
return this;
}
setRole(role: 'admin' | 'user'): this {
this.user.role = role;
return this;
}
build(): User {
// Runtime validation of required fields
if (!this.user.id || !this.user.name || !this.user.email) {
throw new Error('Missing required fields');
}
return this.user as User;
}
}
// Usage
const user = new UserBuilder()
.setId('123')
.setName('John')
.setEmail('john@example.com')
.setAge(30)
.build();
Type-Safe Builder (Compile-Time Enforcement)
type RequiredKeys<T> = {
[K in keyof T]-?: {} extends Pick<T, K> ? never : K
}[keyof T];
type OptionalKeys<T> = {
[K in keyof T]-?: {} extends Pick<T, K> ? K : never
}[keyof T];
type Builder<T, Set extends keyof T = never> = {
[K in RequiredKeys<T> as `set${Capitalize<K & string>}`]: (
value: T[K]
) => Builder<T, Set | K>;
} & {
[K in OptionalKeys<T> as `set${Capitalize<K & string>}`]: (
value: T[K]
) => Builder<T, Set>;
} & (RequiredKeys<T> extends Set
? { build(): T }
: { build: 'Error: Missing required fields' }
);
function createBuilder<T>(): Builder<T> {
const data: Partial<T> = {};
return new Proxy({} as Builder<T>, {
get(_, prop: string) {
if (prop === 'build') {
return () => data as T;
}
if (prop.startsWith('set')) {
return (value: unknown) => {
const key = prop.slice(3).toLowerCase() as keyof T;
data[key] = value as T[keyof T];
return this;
};
}
return undefined;
}
});
}
// Usage - compile-time enforcement!
const builder = createBuilder<User>();
// builder.build(); // ✗ Error: Missing required fields
const userBuilder = builder
.setId('123')
.setName('John');
// .build(); // ✗ Still error: missing 'email'
const completeUser = userBuilder
.setEmail('john@example.com')
.build(); // ✓ Now it works!
Fluent API Builder
type FluentBuilder<T, Required extends keyof T = never> = {
[K in keyof T]: (value: T[K]) => FluentBuilder<T, Required | K>;
} & ([Required] extends [keyof T]
? { build(): T }
: {}
);
function fluent<T>(): FluentBuilder<T> {
const data: Partial<T> = {};
return new Proxy({} as FluentBuilder<T>, {
get(_, prop: string) {
if (prop === 'build') {
return () => data as T;
}
return (value: unknown) => {
data[prop as keyof T] = value as T[keyof T];
return this;
};
}
});
}
// Usage
const user = fluent<User>()
.id('123')
.name('John')
.email('john@example.com')
.age(30)
.build();
Immutable Builder
class ImmutableUserBuilder {
private constructor(private readonly user: Partial<User>) {}
static create(): ImmutableUserBuilder {
return new ImmutableUserBuilder({});
}
setId(id: string): ImmutableUserBuilder {
return new ImmutableUserBuilder({ ...this.user, id });
}
setName(name: string): ImmutableUserBuilder {
return new ImmutableUserBuilder({ ...this.user, name });
}
setEmail(email: string): ImmutableUserBuilder {
return new ImmutableUserBuilder({ ...this.user, email });
}
build(): User {
if (!this.user.id || !this.user.name || !this.user.email) {
throw new Error('Missing required fields');
}
return this.user as User;
}
}
// Usage
const user = ImmutableUserBuilder.create()
.setId('123')
.setName('John')
.setEmail('john@example.com')
.build();
Best Practices
- Type Safety: Use type-level programming to enforce required fields
- Immutability: Consider making builders immutable
- Fluent API: Return
thisfor method chaining - Validation: Validate required fields at build time
- Defaults: Provide sensible defaults for optional fields
See Also
state-machine.mdin this skill — another pattern that enforces valid transitions at compile timeillegal-states.mdin the siblingtypescript-practicesskill — the general principle this pattern appliesmapped-types.mdin the siblingtypescript-type-systemskill — the mapped-type mechanics builder types rely on
Deep Readonly and Deep Partial
Recursive utility types for nested objects.
Deep Readonly
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object
? T[K] extends Function
? T[K]
: DeepReadonly<T[K]>
: T[K];
};
interface Config {
server: {
host: string;
port: number;
ssl: {
enabled: boolean;
cert: string;
};
};
database: {
host: string;
credentials: {
username: string;
password: string;
};
};
}
type ReadonlyConfig = DeepReadonly<Config>;
const config: ReadonlyConfig = {
server: {
host: 'localhost',
port: 3000,
ssl: {
enabled: true,
cert: '/path/to/cert'
}
},
database: {
host: 'localhost',
credentials: {
username: 'user',
password: 'pass'
}
}
};
// config.server.port = 4000; // ✗ Error: readonly
// config.server.ssl.enabled = false; // ✗ Error: readonly
// config.database.credentials.password = 'new'; // ✗ Error: readonly
Deep Partial
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object
? T[K] extends Function
? T[K]
: DeepPartial<T[K]>
: T[K];
};
function updateConfig(
config: Config,
updates: DeepPartial<Config>
): Config {
return {
...config,
...updates,
server: { ...config.server, ...updates.server },
database: { ...config.database, ...updates.database }
};
}
// Usage - all fields optional at all levels
const updated = updateConfig(config, {
server: {
port: 4000,
ssl: {
enabled: false
}
}
}); // ✓
Deep Required
type DeepRequired<T> = {
[K in keyof T]-?: T[K] extends object
? T[K] extends Function
? T[K]
: DeepRequired<T[K]>
: T[K];
};
interface PartialConfig {
server?: {
host?: string;
port?: number;
};
database?: {
host?: string;
};
}
type CompleteConfig = DeepRequired<PartialConfig>;
// All nested fields are now required
Deep Mutable
type DeepMutable<T> = {
-readonly [K in keyof T]: T[K] extends object
? T[K] extends Function
? T[K]
: DeepMutable<T[K]>
: T[K];
};
type MutableConfig = DeepMutable<ReadonlyConfig>;
const mutable: MutableConfig = { /* ... */ };
mutable.server.port = 4000; // ✓ Now allowed
Deep Freeze (Runtime)
function deepFreeze<T extends object>(obj: T): DeepReadonly<T> {
Object.freeze(obj);
Object.keys(obj).forEach(key => {
const value = obj[key as keyof T];
if (typeof value === 'object' && value !== null && !Object.isFrozen(value)) {
deepFreeze(value);
}
});
return obj as DeepReadonly<T>;
}
// Usage
const frozen = deepFreeze(config);
// frozen.server.port = 4000; // ✗ Runtime error
Handling Arrays and Tuples
type DeepReadonlyArray<T> = {
readonly [K in keyof T]: T[K] extends object
? T[K] extends (infer U)[]
? ReadonlyArray<DeepReadonly<U>>
: DeepReadonly<T[K]>
: T[K];
};
interface DataWithArrays {
items: Array<{
id: number;
tags: string[];
}>;
}
type ReadonlyData = DeepReadonlyArray<DataWithArrays>;
// items is ReadonlyArray<{ readonly id: number; readonly tags: ReadonlyArray<string> }>
Handling Maps and Sets
type DeepReadonlyWithCollections<T> = {
readonly [K in keyof T]: T[K] extends Map<infer K, infer V>
? ReadonlyMap<K, DeepReadonly<V>>
: T[K] extends Set<infer U>
? ReadonlySet<DeepReadonly<U>>
: T[K] extends object
? DeepReadonly<T[K]>
: T[K];
};
Best Practices
- Immutability: Use DeepReadonly for configuration objects
- Updates: Use DeepPartial for update/patch operations
- Runtime Enforcement: Combine with Object.freeze for runtime immutability
- Performance: Be aware that deep recursion can impact compilation time
- Collections: Handle arrays, Maps, and Sets appropriately
Common Pitfalls
- Functions: Don’t try to make functions readonly
- Circular References: Deep types can cause infinite recursion
- Performance: Very deep nesting can slow down TypeScript compiler
- Runtime vs Compile-Time: Type-level readonly doesn’t prevent runtime mutation
See Also
mapped-types.mdin the siblingtypescript-type-systemskill — the mapped-type mechanics these recursive utilities build onreadonly-record.mdin the siblingtypescript-utility-typesskill — the shallow, built-inReadonly<T>this pattern generalisesillegal-states.mdin the siblingtypescript-practicesskill — the general principle immutable config types apply
Dependency Injection Pattern
Implement type-safe dependency injection for testable, maintainable code.
Basic DI Container
type ServiceFactory<T> = () => T;
type ServiceIdentifier<T> = symbol & { __type?: T };
class Container {
private services = new Map<symbol, ServiceFactory<any>>();
register<T>(id: ServiceIdentifier<T>, factory: ServiceFactory<T>): void {
this.services.set(id, factory);
}
resolve<T>(id: ServiceIdentifier<T>): T {
const factory = this.services.get(id);
if (!factory) {
throw new Error(`Service not registered: ${id.toString()}`);
}
return factory();
}
}
// Service identifiers
const DATABASE_SERVICE = Symbol('database') as ServiceIdentifier<Database>;
const USER_SERVICE = Symbol('user') as ServiceIdentifier<UserService>;
// Registration
const container = new Container();
container.register(DATABASE_SERVICE, () => new Database());
container.register(USER_SERVICE, () => {
const db = container.resolve(DATABASE_SERVICE);
return new UserService(db);
});
// Resolution
const userService = container.resolve(USER_SERVICE);
Constructor Injection
interface Database {
query<T>(sql: string): Promise<T[]>;
}
interface Logger {
log(message: string): void;
}
class UserRepository {
constructor(
private readonly db: Database,
private readonly logger: Logger
) {}
async findById(id: string) {
this.logger.log(`Finding user: ${id}`);
return this.db.query(`SELECT * FROM users WHERE id = ?`);
}
}
// DI Container provides dependencies
const db: Database = createDatabase();
const logger: Logger = createLogger();
const userRepo = new UserRepository(db, logger);
Token-based Injection
interface InjectionToken<T> {
readonly _type?: T;
}
function createToken<T>(description: string): InjectionToken<T> {
return { _type: undefined } as InjectionToken<T>;
}
const DB_TOKEN = createToken<Database>('Database');
const LOGGER_TOKEN = createToken<Logger>('Logger');
const USER_REPO_TOKEN = createToken<UserRepository>('UserRepository');
class TypedContainer {
private providers = new Map<InjectionToken<any>, any>();
provide<T>(token: InjectionToken<T>, value: T): void {
this.providers.set(token, value);
}
inject<T>(token: InjectionToken<T>): T {
const value = this.providers.get(token);
if (!value) {
throw new Error('Token not provided');
}
return value;
}
}
// Usage
const container = new TypedContainer();
container.provide(DB_TOKEN, createDatabase());
container.provide(LOGGER_TOKEN, createLogger());
container.provide(
USER_REPO_TOKEN,
new UserRepository(
container.inject(DB_TOKEN),
container.inject(LOGGER_TOKEN)
)
);
Scoped Services
type ServiceLifetime = 'singleton' | 'transient' | 'scoped';
interface ServiceDescriptor<T> {
lifetime: ServiceLifetime;
factory: ServiceFactory<T>;
instance?: T;
}
class ScopedContainer {
private descriptors = new Map<symbol, ServiceDescriptor<any>>();
private scopeInstances = new Map<symbol, any>();
register<T>(
id: symbol,
factory: ServiceFactory<T>,
lifetime: ServiceLifetime = 'transient'
): void {
this.descriptors.set(id, { lifetime, factory });
}
resolve<T>(id: symbol): T {
const descriptor = this.descriptors.get(id);
if (!descriptor) {
throw new Error('Service not registered');
}
switch (descriptor.lifetime) {
case 'singleton':
if (!descriptor.instance) {
descriptor.instance = descriptor.factory();
}
return descriptor.instance;
case 'scoped':
if (!this.scopeInstances.has(id)) {
this.scopeInstances.set(id, descriptor.factory());
}
return this.scopeInstances.get(id);
case 'transient':
return descriptor.factory();
}
}
createScope(): ScopedContainer {
const scope = new ScopedContainer();
scope.descriptors = this.descriptors;
return scope;
}
}
Testing with DI
// Production
class RealDatabase implements Database {
async query<T>(sql: string): Promise<T[]> {
// Real database query
return [];
}
}
// Test
class MockDatabase implements Database {
queries: string[] = [];
async query<T>(sql: string): Promise<T[]> {
this.queries.push(sql);
return [] as T[];
}
}
// Test setup
const mockDb = new MockDatabase();
const logger = createLogger();
const userRepo = new UserRepository(mockDb, logger);
await userRepo.findById('123');
expect(mockDb.queries).toContain('SELECT * FROM users WHERE id = ?');
Best Practices
- Use interfaces: Depend on abstractions, not concrete classes
- Constructor injection: Prefer constructor over property injection
- Explicit dependencies: Make dependencies visible in constructor
- Avoid service locator: Pass dependencies explicitly
- Lifetime management: Choose appropriate service lifetimes
- Type safety: Use tokens or symbols for type-safe resolution
Type-Safe Event Emitter
Building event emitters with full type safety for event names and payloads.
Basic Event Emitter
type Events = {
'user:login': { userId: string; timestamp: Date };
'user:logout': { userId: string };
'data:update': { id: number; data: unknown };
};
class TypedEventEmitter<T extends Record<string, unknown>> {
private listeners = new Map<keyof T, Set<(payload: unknown) => void>>();
on<K extends keyof T>(event: K, handler: (payload: T[K]) => void): void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(handler as (payload: unknown) => void);
}
emit<K extends keyof T>(event: K, payload: T[K]): void {
const handlers = this.listeners.get(event);
if (handlers) {
handlers.forEach(handler => handler(payload));
}
}
off<K extends keyof T>(event: K, handler: (payload: T[K]) => void): void {
const handlers = this.listeners.get(event);
if (handlers) {
handlers.delete(handler as (payload: unknown) => void);
}
}
}
// Usage with full type safety
const emitter = new TypedEventEmitter<Events>();
emitter.on('user:login', (payload) => {
// payload is typed as { userId: string; timestamp: Date }
console.log(payload.userId, payload.timestamp);
});
emitter.emit('user:login', {
userId: '123',
timestamp: new Date()
}); // ✓
// emitter.emit('user:login', { wrong: 'data' }); // ✗ Type error
With Once Support
class EnhancedEventEmitter<T extends Record<string, unknown>> {
private listeners = new Map<keyof T, Set<(payload: unknown) => void>>();
private onceListeners = new Map<keyof T, Set<(payload: unknown) => void>>();
once<K extends keyof T>(event: K, handler: (payload: T[K]) => void): void {
if (!this.onceListeners.has(event)) {
this.onceListeners.set(event, new Set());
}
this.onceListeners.get(event)!.add(handler as (payload: unknown) => void);
}
emit<K extends keyof T>(event: K, payload: T[K]): void {
// Call regular listeners
const handlers = this.listeners.get(event);
if (handlers) {
handlers.forEach(handler => handler(payload));
}
// Call and remove once listeners
const onceHandlers = this.onceListeners.get(event);
if (onceHandlers) {
onceHandlers.forEach(handler => handler(payload));
this.onceListeners.delete(event);
}
}
}
Async Event Emitter
class AsyncEventEmitter<T extends Record<string, unknown>> {
private listeners = new Map<keyof T, Set<(payload: unknown) => Promise<void>>>();
on<K extends keyof T>(event: K, handler: (payload: T[K]) => Promise<void>): void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(handler as (payload: unknown) => Promise<void>);
}
async emit<K extends keyof T>(event: K, payload: T[K]): Promise<void> {
const handlers = this.listeners.get(event);
if (handlers) {
await Promise.all(
Array.from(handlers).map(handler => handler(payload))
);
}
}
}
Best Practices
- Define Event Types: Use a type alias for all events
- Strong Typing: Ensure event names and payloads are strictly typed
- Memory Management: Provide
offmethod to prevent memory leaks - Error Handling: Wrap handler calls in try-catch blocks
- Async Support: Use AsyncEventEmitter for async handlers
See Also
builder.mdin this skill — a complementary pattern for constructing typed configuration objectsstate-machine.mdin this skill — modelling the states an emitter’s consumers transition throughbasic-guards.mdandgeneric-guards.mdin the siblingtypescript-type-guardsskill — narrowing emitted payloads by event type
Type-Safe Form Validation
Building type-safe form validation with field-level and form-level validation.
Basic Form Validation
type ValidationError = string;
type ValidationResult<T> =
| { success: true; value: T }
| { success: false; errors: Record<keyof T, ValidationError> };
type Validator<T> = (value: T) => ValidationError | null;
interface FormValidators<T> {
[K in keyof T]: Validator<T[K]>;
}
function createForm<T extends Record<string, unknown>>(
validators: FormValidators<T>
) {
return {
validate(data: T): ValidationResult<T> {
const errors: Partial<Record<keyof T, ValidationError>> = {};
let hasErrors = false;
for (const key in validators) {
const error = validators[key](data[key]);
if (error) {
errors[key] = error;
hasErrors = true;
}
}
if (hasErrors) {
return {
success: false,
errors: errors as Record<keyof T, ValidationError>
};
}
return { success: true, value: data };
}
};
}
// Usage
interface UserForm {
name: string;
email: string;
age: number;
}
const userForm = createForm<UserForm>({
name: (value) => value.length < 2 ? 'Name too short' : null,
email: (value) => !value.includes('@') ? 'Invalid email' : null,
age: (value) => value < 18 ? 'Must be 18+' : null
});
const result = userForm.validate({
name: 'John',
email: 'john@example.com',
age: 25
});
if (result.success) {
console.log('Valid:', result.value);
} else {
console.log('Errors:', result.errors);
}
With Async Validation
type AsyncValidator<T> = (value: T) => Promise<ValidationError | null>;
interface AsyncFormValidators<T> {
[K in keyof T]: Validator<T[K]> | AsyncValidator<T[K]>;
}
function createAsyncForm<T extends Record<string, unknown>>(
validators: AsyncFormValidators<T>
) {
return {
async validate(data: T): Promise<ValidationResult<T>> {
const errors: Partial<Record<keyof T, ValidationError>> = {};
let hasErrors = false;
for (const key in validators) {
const error = await validators[key](data[key]);
if (error) {
errors[key] = error;
hasErrors = true;
}
}
if (hasErrors) {
return {
success: false,
errors: errors as Record<keyof T, ValidationError>
};
}
return { success: true, value: data };
}
};
}
// Usage with async validators
const asyncUserForm = createAsyncForm<UserForm>({
name: (value) => value.length < 2 ? 'Name too short' : null,
email: async (value) => {
const exists = await checkEmailExists(value);
return exists ? 'Email already taken' : null;
},
age: (value) => value < 18 ? 'Must be 18+' : null
});
With Field Dependencies
type CrossFieldValidator<T> = (data: T) => Record<keyof T, ValidationError> | null;
function createFormWithCrossValidation<T extends Record<string, unknown>>(
validators: FormValidators<T>,
crossValidators: CrossFieldValidator<T>[]
) {
return {
validate(data: T): ValidationResult<T> {
const errors: Partial<Record<keyof T, ValidationError>> = {};
let hasErrors = false;
// Field-level validation
for (const key in validators) {
const error = validators[key](data[key]);
if (error) {
errors[key] = error;
hasErrors = true;
}
}
// Cross-field validation
for (const crossValidator of crossValidators) {
const crossErrors = crossValidator(data);
if (crossErrors) {
Object.assign(errors, crossErrors);
hasErrors = true;
}
}
if (hasErrors) {
return {
success: false,
errors: errors as Record<keyof T, ValidationError>
};
}
return { success: true, value: data };
}
};
}
// Usage
interface PasswordForm {
password: string;
confirmPassword: string;
}
const passwordForm = createFormWithCrossValidation<PasswordForm>(
{
password: (value) => value.length < 8 ? 'Password too short' : null,
confirmPassword: () => null // Validated in cross-field
},
[
(data) => {
if (data.password !== data.confirmPassword) {
return { confirmPassword: 'Passwords do not match' };
}
return null;
}
]
);
With Zod Integration
import { z } from 'zod';
function createZodForm<T extends z.ZodRawShape>(schema: z.ZodObject<T>) {
type FormData = z.infer<z.ZodObject<T>>;
return {
validate(data: FormData): ValidationResult<FormData> {
const result = schema.safeParse(data);
if (result.success) {
return { success: true, value: result.data };
}
const errors: Partial<Record<keyof FormData, ValidationError>> = {};
result.error.errors.forEach(err => {
if (err.path.length > 0) {
errors[err.path[0] as keyof FormData] = err.message;
}
});
return {
success: false,
errors: errors as Record<keyof FormData, ValidationError>
};
}
};
}
// Usage
const userSchema = z.object({
name: z.string().min(2, 'Name too short'),
email: z.string().email('Invalid email'),
age: z.number().min(18, 'Must be 18+')
});
const zodForm = createZodForm(userSchema);
const result = zodForm.validate({
name: 'John',
email: 'john@example.com',
age: 25
});
Best Practices
- Type Safety: Use generics to ensure validators match form fields
- Async Validation: Support async validators for server-side checks
- Cross-Field Validation: Validate field dependencies explicitly
- Error Messages: Provide clear, actionable error messages
- Integration: Use validation libraries like Zod for complex schemas
See Also
runtime-validation.mdin the siblingtypescript-practicesskill — Zod schema validation this pattern composes withbasic-guards.mdandgeneric-guards.mdin the siblingtypescript-type-guardsskill — the type predicates behind field-level validatorsstate-machine.mdin this skill — modelling multi-step form/wizard flows as a discriminated union
Plugin System Architecture
Build extensible applications with type-safe plugin systems.
Basic Plugin Interface
interface Plugin {
readonly name: string;
readonly version: string;
init(): Promise<void>;
cleanup(): Promise<void>;
}
interface PluginManager {
register(plugin: Plugin): void;
unregister(name: string): Promise<void>;
get(name: string): Plugin | undefined;
list(): Plugin[];
}
Typed Plugin with Configuration
interface PluginConfig<T = unknown> {
name: string;
version: string;
config: T;
}
interface Plugin<TConfig = unknown> {
readonly name: string;
readonly version: string;
init(config: TConfig): Promise<void>;
cleanup(): Promise<void>;
}
// Example: Logger plugin
interface LoggerConfig {
level: 'debug' | 'info' | 'warn' | 'error';
output: 'console' | 'file';
filePath?: string;
}
class LoggerPlugin implements Plugin<LoggerConfig> {
readonly name = 'logger';
readonly version = '1.0.0';
private config?: LoggerConfig;
async init(config: LoggerConfig) {
this.config = config;
console.log(`Logger initialized: ${config.level} -> ${config.output}`);
}
async cleanup() {
console.log('Logger cleaned up');
}
log(level: LoggerConfig['level'], message: string) {
if (!this.config) {
throw new Error('Plugin not initialized');
}
// Logging logic
}
}
Plugin with Hooks
type PluginHook<TData = unknown> = (data: TData) => TData | Promise<TData>;
interface HookablePlugin<THooks extends Record<string, PluginHook>> {
readonly name: string;
hooks: THooks;
}
// Example: Transform plugin
interface TransformHooks {
beforeTransform: PluginHook<string>;
afterTransform: PluginHook<string>;
}
class TransformPlugin implements HookablePlugin<TransformHooks> {
readonly name = 'transform';
hooks: TransformHooks = {
beforeTransform: (data) => data.trim(),
afterTransform: (data) => data.toUpperCase()
};
async transform(input: string): Promise<string> {
let result = await this.hooks.beforeTransform(input);
// Main transformation
result = result.replace(/foo/g, 'bar');
result = await this.hooks.afterTransform(result);
return result;
}
}
Plugin Registry with Type Safety
interface PluginRegistry {
plugins: Map<string, Plugin>;
}
class TypedPluginRegistry implements PluginRegistry {
plugins = new Map<string, Plugin>();
register<T extends Plugin>(plugin: T): void {
if (this.plugins.has(plugin.name)) {
throw new Error(`Plugin ${plugin.name} already registered`);
}
this.plugins.set(plugin.name, plugin);
}
get<T extends Plugin>(name: string): T | undefined {
return this.plugins.get(name) as T | undefined;
}
async unregister(name: string): Promise<void> {
const plugin = this.plugins.get(name);
if (plugin) {
await plugin.cleanup();
this.plugins.delete(name);
}
}
list(): Plugin[] {
return Array.from(this.plugins.values());
}
}
Plugin Lifecycle Management
type PluginState = 'registered' | 'initializing' | 'active' | 'error' | 'disabled';
interface ManagedPlugin<TConfig = unknown> extends Plugin<TConfig> {
state: PluginState;
error?: Error;
}
class PluginManager {
private plugins = new Map<string, ManagedPlugin>();
async register<TConfig>(
plugin: Plugin<TConfig>,
config: TConfig
): Promise<void> {
const managed: ManagedPlugin<TConfig> = {
...plugin,
state: 'registered'
};
this.plugins.set(plugin.name, managed);
try {
managed.state = 'initializing';
await plugin.init(config);
managed.state = 'active';
} catch (error) {
managed.state = 'error';
managed.error = error as Error;
throw error;
}
}
getState(name: string): PluginState | undefined {
return this.plugins.get(name)?.state;
}
}
Plugin Dependencies
interface PluginWithDeps<TConfig = unknown> extends Plugin<TConfig> {
dependencies: string[];
}
class DependencyAwareManager {
private plugins = new Map<string, PluginWithDeps>();
async register<TConfig>(
plugin: PluginWithDeps<TConfig>,
config: TConfig
): Promise<void> {
// Check dependencies
for (const dep of plugin.dependencies) {
if (!this.plugins.has(dep)) {
throw new Error(
`Missing dependency: ${dep} required by ${plugin.name}`
);
}
}
this.plugins.set(plugin.name, plugin);
await plugin.init(config);
}
// Topological sort for initialization order
private getInitOrder(): string[] {
// Implementation of topological sort
return [];
}
}
Best Practices
- Define clear interfaces: Separate plugin contract from implementation
- Version plugins: Track compatibility
- Manage lifecycle: Init, active, cleanup states
- Handle dependencies: Ensure plugins load in correct order
- Type configurations: Each plugin type has specific config
- Error handling: Gracefully handle plugin failures
- Isolation: Plugins shouldn’t access each other directly
Recursive Types
Define types that reference themselves for nested structures.
JSON Value
type JSONValue =
| string
| number
| boolean
| null
| JSONValue[]
| { [key: string]: JSONValue };
const data: JSONValue = {
name: 'Alice',
age: 30,
address: {
street: '123 Main St',
city: 'Boston'
},
hobbies: ['reading', 'coding']
};
Deep Partial
type DeepPartial<T> = T extends object
? { [P in keyof T]?: DeepPartial<T[P]> }
: T;
interface User {
id: string;
profile: {
name: string;
address: {
street: string;
city: string;
};
};
}
const update: DeepPartial<User> = {
profile: {
address: {
city: 'New York' // Other fields optional
}
}
};
Deep Readonly
type DeepReadonly<T> = T extends object
? { readonly [P in keyof T]: DeepReadonly<T[P]> }
: T;
interface Config {
database: {
host: string;
credentials: {
username: string;
password: string;
};
};
}
const config: DeepReadonly<Config> = {
database: {
host: 'localhost',
credentials: {
username: 'admin',
password: 'secret'
}
}
};
// All levels are readonly
// config.database.host = 'new'; // Error
// config.database.credentials.password = 'new'; // Error
Path Type
type Path<T> = T extends object
? {
[K in keyof T]: K extends string
? T[K] extends object
? K | `${K}.${Path<T[K]>}`
: K
: never;
}[keyof T]
: never;
interface Data {
user: {
profile: {
name: string;
age: number;
};
settings: {
theme: string;
};
};
}
// Valid paths
type DataPath = Path<Data>;
// "user" | "user.profile" | "user.profile.name" | "user.profile.age"
// | "user.settings" | "user.settings.theme"
const path1: DataPath = 'user.profile.name'; // OK
const path2: DataPath = 'user.invalid'; // Error
Tree Structure
interface TreeNode<T> {
value: T;
children: TreeNode<T>[];
}
const tree: TreeNode<number> = {
value: 1,
children: [
{
value: 2,
children: [
{ value: 4, children: [] },
{ value: 5, children: [] }
]
},
{ value: 3, children: [] }
]
};
// Recursive function with recursive type
function sumTree(node: TreeNode<number>): number {
return node.value + node.children.reduce(
(sum, child) => sum + sumTree(child),
0
);
}
Best Practices
- Set recursion limits: TypeScript has depth limits (~50 levels)
- Use tail recursion: When possible for better performance
- Test edge cases: Empty structures, single nodes
- Document structure: Explain the recursive nature
- Consider alternatives: Sometimes iteration is clearer
Type-Safe State Machines
Implementing state machines with discriminated unions for compile-time state validation.
Basic State Machine
type State =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: string }
| { status: 'error'; error: Error };
type Event =
| { type: 'FETCH' }
| { type: 'SUCCESS'; data: string }
| { type: 'ERROR'; error: Error }
| { type: 'RESET' };
function reducer(state: State, event: Event): State {
switch (state.status) {
case 'idle':
switch (event.type) {
case 'FETCH':
return { status: 'loading' };
default:
return state;
}
case 'loading':
switch (event.type) {
case 'SUCCESS':
return { status: 'success', data: event.data };
case 'ERROR':
return { status: 'error', error: event.error };
default:
return state;
}
case 'success':
case 'error':
switch (event.type) {
case 'RESET':
return { status: 'idle' };
case 'FETCH':
return { status: 'loading' };
default:
return state;
}
}
}
// Usage
let state: State = { status: 'idle' };
state = reducer(state, { type: 'FETCH' });
// state is now { status: 'loading' }
state = reducer(state, { type: 'SUCCESS', data: 'Hello' });
// state is now { status: 'success', data: 'Hello' }
if (state.status === 'success') {
console.log(state.data); // TypeScript knows data exists here
}
Generic State Machine
type StateMachine<
States extends { status: string },
Events extends { type: string }
> = {
initialState: States;
reducer: (state: States, event: Events) => States;
};
function createStateMachine<
States extends { status: string },
Events extends { type: string }
>(config: StateMachine<States, Events>) {
let state = config.initialState;
return {
getState: () => state,
dispatch: (event: Events) => {
state = config.reducer(state, event);
return state;
},
};
}
// Usage
const machine = createStateMachine<State, Event>({
initialState: { status: 'idle' },
reducer
});
machine.dispatch({ type: 'FETCH' });
console.log(machine.getState()); // { status: 'loading' }
With Type Guards
function isLoading(state: State): state is { status: 'loading' } {
return state.status === 'loading';
}
function isSuccess(state: State): state is { status: 'success'; data: string } {
return state.status === 'success';
}
function isError(state: State): state is { status: 'error'; error: Error } {
return state.status === 'error';
}
// Usage with type guards
const currentState = machine.getState();
if (isSuccess(currentState)) {
console.log(currentState.data); // Type-safe access to data
}
if (isError(currentState)) {
console.log(currentState.error.message); // Type-safe access to error
}
Complex State Machine (Order Processing)
type OrderState =
| { status: 'pending'; orderId: string }
| { status: 'payment_processing'; orderId: string; paymentId: string }
| { status: 'confirmed'; orderId: string; paymentId: string; confirmationNumber: string }
| { status: 'shipping'; orderId: string; trackingNumber: string }
| { status: 'delivered'; orderId: string; deliveredAt: Date }
| { status: 'cancelled'; orderId: string; reason: string };
type OrderEvent =
| { type: 'PROCESS_PAYMENT'; paymentId: string }
| { type: 'PAYMENT_SUCCESS'; confirmationNumber: string }
| { type: 'PAYMENT_FAILED' }
| { type: 'SHIP'; trackingNumber: string }
| { type: 'DELIVER'; deliveredAt: Date }
| { type: 'CANCEL'; reason: string };
function orderReducer(state: OrderState, event: OrderEvent): OrderState {
switch (state.status) {
case 'pending':
if (event.type === 'PROCESS_PAYMENT') {
return {
status: 'payment_processing',
orderId: state.orderId,
paymentId: event.paymentId
};
}
if (event.type === 'CANCEL') {
return {
status: 'cancelled',
orderId: state.orderId,
reason: event.reason
};
}
return state;
case 'payment_processing':
if (event.type === 'PAYMENT_SUCCESS') {
return {
status: 'confirmed',
orderId: state.orderId,
paymentId: state.paymentId,
confirmationNumber: event.confirmationNumber
};
}
if (event.type === 'PAYMENT_FAILED') {
return { status: 'pending', orderId: state.orderId };
}
return state;
case 'confirmed':
if (event.type === 'SHIP') {
return {
status: 'shipping',
orderId: state.orderId,
trackingNumber: event.trackingNumber
};
}
return state;
case 'shipping':
if (event.type === 'DELIVER') {
return {
status: 'delivered',
orderId: state.orderId,
deliveredAt: event.deliveredAt
};
}
return state;
case 'delivered':
case 'cancelled':
// Terminal states
return state;
}
}
With Middleware
type Middleware<S, E> = (
state: S,
event: E,
next: (state: S, event: E) => S
) => S;
function createStateMachineWithMiddleware<
States extends { status: string },
Events extends { type: string }
>(
config: StateMachine<States, Events>,
middleware: Middleware<States, Events>[]
) {
let state = config.initialState;
const applyMiddleware = (s: States, e: Events): States => {
let index = 0;
const next = (currentState: States, currentEvent: Events): States => {
if (index >= middleware.length) {
return config.reducer(currentState, currentEvent);
}
const mw = middleware[index++];
return mw(currentState, currentEvent, next);
};
return next(s, e);
};
return {
getState: () => state,
dispatch: (event: Events) => {
state = applyMiddleware(state, event);
return state;
},
};
}
// Usage with logging middleware
const loggingMiddleware: Middleware<State, Event> = (state, event, next) => {
console.log('Before:', state, event);
const newState = next(state, event);
console.log('After:', newState);
return newState;
};
const machineWithLogging = createStateMachineWithMiddleware<State, Event>(
{ initialState: { status: 'idle' }, reducer },
[loggingMiddleware]
);
Best Practices
- Discriminated Unions: Use discriminated unions for states
- Exhaustive Checking: Use switch statements with never for exhaustive checking
- Type Guards: Create type guard functions for common state checks
- Immutability: Always return new state objects
- Middleware: Use middleware for cross-cutting concerns like logging
See Also
illegal-states.mdin the siblingtypescript-practicesskill — the general principle state machines applydiscriminated-unions.mdin the siblingtypescript-type-guardsskill — the union-narrowing mechanics state transitions rely onbasic-guards.mdandgeneric-guards.mdin the siblingtypescript-type-guardsskill — writing state-check predicates
Type-Safe Module Pattern
Create modules with full type safety and encapsulation.
Basic Module Pattern
// math.ts
export interface MathOperations {
add(a: number, b: number): number;
subtract(a: number, b: number): number;
}
const createMath = (): MathOperations => {
// Private implementation
const cache = new Map<string, number>();
const getCacheKey = (op: string, a: number, b: number) =>
`${op}:${a}:${b}`;
return {
add(a, b) {
const key = getCacheKey('add', a, b);
if (!cache.has(key)) {
cache.set(key, a + b);
}
return cache.get(key)!;
},
subtract(a, b) {
const key = getCacheKey('sub', a, b);
if (!cache.has(key)) {
cache.set(key, a - b);
}
return cache.get(key)!;
}
};
};
export const math = createMath();
Plugin Architecture
interface Plugin<TConfig = unknown> {
name: string;
version: string;
init(config: TConfig): Promise<void>;
cleanup(): Promise<void>;
}
interface PluginRegistry {
register<TConfig>(plugin: Plugin<TConfig>): void;
get<TConfig>(name: string): Plugin<TConfig> | undefined;
unregister(name: string): void;
}
const createPluginRegistry = (): PluginRegistry => {
const plugins = new Map<string, Plugin>();
return {
register(plugin) {
if (plugins.has(plugin.name)) {
throw new Error(`Plugin ${plugin.name} already registered`);
}
plugins.set(plugin.name, plugin);
},
get(name) {
return plugins.get(name);
},
unregister(name) {
const plugin = plugins.get(name);
if (plugin) {
plugin.cleanup();
plugins.delete(name);
}
}
};
};
Module with Private State
export type UserId = string & { readonly __brand: 'UserId' };
export interface UserModule {
create(email: string): UserId;
get(id: UserId): { id: UserId; email: string } | undefined;
delete(id: UserId): boolean;
}
const createUserModule = (): UserModule => {
let counter = 0;
const users = new Map<UserId, { id: UserId; email: string }>();
const createUserId = (): UserId => {
return `user-${++counter}` as UserId;
};
return {
create(email) {
const id = createUserId();
users.set(id, { id, email });
return id;
},
get(id) {
return users.get(id);
},
delete(id) {
return users.delete(id);
}
};
};
export const userModule = createUserModule();
Best Practices
- Export only interfaces: Keep implementation details private
- Use branded types: Prevent ID mixing across modules
- Single instance: Export one instance, not the factory
- Clear boundaries: Define what’s public vs private
- Testability: Allow dependency injection for testing