
Typescript Advanced Patterns
- 93 installs
- 178 repo stars
- Updated July 14, 2026
- erichowens/some_claude_skills
Encode domain constraints at compile time using branded types, discriminated unions, and type inference.
About
Advanced type system patterns eliminating runtime bugs via nominal typing, mapped types, conditional types with infer, and Zod schema inference. Covers type-safe events and exhaustive checking.
- Branded types for nominal typing without runtime wrappers
- Zod schema + z.infer for compile-time and runtime safety
Typescript Advanced Patterns by the numbers
- 93 all-time installs (skills.sh)
- Ranked #458 of 1,352 Code Review & Quality skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/erichowens/some_claude_skills --skill typescript-advanced-patternsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 93 |
|---|---|
| repo stars | ★ 178 |
| Last updated | July 14, 2026 |
| Repository | erichowens/some_claude_skills ↗ |
What it does
Encode domain constraints at compile time using branded types, discriminated unions, and type inference.
Files
TypeScript Advanced Patterns
Advanced type system patterns that eliminate runtime bugs by encoding constraints at compile time.
When to Use
Activate on: "branded types", "nominal typing", "discriminated union", "template literal types", "conditional types", "infer keyword", "satisfies operator", "const assertion", "Zod inference", "exhaustive switch", "type-safe event emitter", "mapped types", "utility types", "generic constraints", "type narrowing", "as const"
NOT for: Basic TypeScript syntax | React component prop types | General JavaScript patterns
Decision Tree: Which Advanced Pattern for Your Problem?
flowchart TD
P[What is your problem?] --> P1[I'm mixing up\nvalues of the same type\ne.g. UserId vs OrderId]
P[What is your problem?] --> P2[I have a value that\ncan be one of N shapes]
P[What is your problem?] --> P3[I need type-safe\nevent emitting]
P[What is your problem?] --> P4[I'm parsing external\ndata and want types]
P[What is your problem?] --> P5[I want types that\ndepend on other types]
P[What is your problem?] --> P6[My switch/if is\nnot exhaustive]
P1 --> S1[Branded Types\nfor nominal typing]
P2 --> S2[Discriminated Unions\nwith type narrowing]
P3 --> S3[Type-Safe Event Emitter\nwith mapped types]
P4 --> S4[Zod schema + z.infer\nfor runtime + compile time]
P5 --> S5[Conditional Types\nwith infer keyword]
P6 --> S6[Exhaustive checking\nwith never type]Core Patterns
1. Branded Types (Nominal Typing)
TypeScript's structural type system means UserId and string are assignable to each other. Branded types add a phantom brand that makes them incompatible.
// Without branding: these compile without error
function chargeUser(userId: string, amount: number) { /* ... */ }
const orderId = getOrderId();
chargeUser(orderId, 100); // Wrong! orderId passed as userId — TypeScript allows it
// With branding: compile-time protection
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
type EmailAddress = Brand<string, 'EmailAddress'>;
type Cents = Brand<number, 'Cents'>; // Never pass raw numbers as money
// Constructor functions (only way to create branded values)
const UserId = (id: string): UserId => id as UserId;
const OrderId = (id: string): OrderId => id as OrderId;
const Cents = (n: number): Cents => {
if (!Number.isInteger(n) || n < 0) throw new Error(`Invalid cents: ${n}`);
return n as Cents;
};
// Usage: type error if you mix them up
function chargeUser(userId: UserId, amount: Cents) { /* ... */ }
const uid = UserId('user_123');
const oid = OrderId('order_456');
chargeUser(uid, Cents(1000)); // OK
chargeUser(oid, Cents(1000)); // Error: Argument of type 'OrderId' is not assignable to 'UserId'
chargeUser(uid, 1000); // Error: Argument of type 'number' is not assignable to 'Cents'See references/branded-types.md for Zod integration and database model patterns.
2. Discriminated Unions with Type Narrowing
The kind (or type, tag) field is the discriminant. TypeScript narrows the union when you check it.
type ApiResult<T> =
| { status: 'success'; data: T }
| { status: 'error'; code: number; message: string }
| { status: 'loading' };
function handleResult<T>(result: ApiResult<T>): T | null {
switch (result.status) {
case 'success': return result.data; // TypeScript knows data exists here
case 'error': console.error(result.code, result.message); return null;
case 'loading': return null;
}
}With exhaustive checking — if a new variant is added and the switch is not updated, it's a compile error:
function assertNever(x: never): never {
throw new Error(`Unexpected value: ${JSON.stringify(x)}`);
}
function handleResult<T>(result: ApiResult<T>): T | null {
switch (result.status) {
case 'success': return result.data;
case 'error': return null;
// If 'loading' is not handled, TypeScript errors:
// Argument of type '{ status: "loading" }' is not assignable to 'never'
default: return assertNever(result);
}
}3. Template Literal Types
Combine string literals at type level to create precise types for string-based APIs:
// Route path types — prevents typos in route definitions
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
type ApiVersion = 'v1' | 'v2';
type Resource = 'users' | 'orders' | 'products';
type ApiEndpoint = `/${ApiVersion}/${Resource}`;
// Type: "/v1/users" | "/v1/orders" | "/v1/products" | "/v2/users" | ...
// CSS-in-JS property types
type CSSProperty = 'margin' | 'padding' | 'border';
type Side = 'top' | 'right' | 'bottom' | 'left';
type CSSPropertyWithSide = `${CSSProperty}-${Side}`;
// Type: "margin-top" | "margin-right" | ... | "border-left"
// Event name types
type DOMEventMap = {
click: MouseEvent;
keydown: KeyboardEvent;
input: InputEvent;
};
type EventName = `on${Capitalize<keyof DOMEventMap>}`;
// Type: "onClick" | "onKeydown" | "onInput"4. Conditional Types with infer
infer extracts a type from within another type during conditional type evaluation.
// Extract the return type of an async function
type Awaited<T> = T extends Promise<infer U> ? U : T;
type UserFetch = () => Promise<{ id: string; name: string }>;
type User = Awaited<ReturnType<UserFetch>>;
// Type: { id: string; name: string }
// Extract the element type of an array
type ElementOf<T> = T extends (infer U)[] ? U : never;
type Names = ElementOf<string[]>; // string
type Events = ElementOf<Array<{ id: number; type: string }>>; // { id: number; type: string }
// Extract handler parameters from an event map
type HandlerParams<T extends (...args: any) => any> =
T extends (...args: infer P) => any ? P : never;
// Deeply unwrap Promises
type DeepAwaited<T> = T extends Promise<infer U> ? DeepAwaited<U> : T;
type Result = DeepAwaited<Promise<Promise<string>>>; // string5. The satisfies Operator (TypeScript 4.9+)
satisfies validates a value against a type without widening it. You get both type checking AND the most specific inferred type.
type Color = string | [number, number, number];
// Problem with 'as': loses specific type info
const colors = {
red: [255, 0, 0] as Color, // type is Color, not [255, 0, 0]
blue: [0, 0, 255] as Color,
} as Record<string, Color>;
// Problem with annotation: same widening
const colors2: Record<string, Color> = {
red: [255, 0, 0], // type is Color
};
// satisfies: validates AND preserves specific type
const colors3 = {
red: [255, 0, 0],
blue: [0, 0, 255],
} satisfies Record<string, Color>;
colors3.red.map(x => x * 2); // OK: TypeScript knows it's [number, number, number]
colors3.blue[0]; // OK: indexed access works
// Great for config objects
const config = {
port: 3000,
host: 'localhost',
debug: false,
} satisfies {
port: number;
host: string;
debug: boolean;
timeout?: number; // optional fields allowed to be absent
};
config.port.toFixed(0); // OK: port is still `number`, not widened to `number | string`6. Zod Schema Inference
Zod validates at runtime and provides TypeScript types for free. Never write a type + validator separately.
import { z } from 'zod';
// Define schema once
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
role: z.enum(['admin', 'user', 'moderator']),
createdAt: z.coerce.date(),
metadata: z.record(z.string(), z.unknown()).optional(),
});
// Extract type — no duplicate type definition
type User = z.infer<typeof UserSchema>;
// Type: { id: string; email: string; role: "admin" | "user" | "moderator"; createdAt: Date; metadata?: Record<string, unknown> }
// Parse with validation
function parseUser(raw: unknown): User {
return UserSchema.parse(raw); // throws ZodError with structured errors on failure
}
// Safe parse (returns Result type)
const result = UserSchema.safeParse(rawData);
if (result.success) {
result.data.role; // narrowed to User
} else {
result.error.issues; // structured validation errors
}
// Derive related schemas
const CreateUserInput = UserSchema.omit({ id: true, createdAt: true });
type CreateUserInput = z.infer<typeof CreateUserInput>;
const UpdateUserInput = UserSchema.partial().required({ id: true });See references/type-safe-patterns.md for type-safe event emitters, builder pattern, and exhaustive checking utilities.
Reference Files
| File | Contents |
|---|---|
references/branded-types.md | Branded primitives for IDs/currencies/emails, Zod integration, database patterns |
references/type-safe-patterns.md | Exhaustive switch, builder pattern, type-safe event emitters, mapped type utilities |
Anti-Patterns (Shibboleths)
Anti-Pattern 1: any as an Escape Hatch Instead of Proper Generics
Novice thinking: "This is too complex to type, I'll just use any and come back to it later."
Why wrong: any disables all type checking for that value AND spreads to anything it touches. any is contagious — once a value is any, functions that receive it infer their return type as any, creating a type hole that grows over time. The "come back to it" never happens.
Detection: as any in a codebase almost always signals a type design problem, not a TypeScript limitation.
Fix — Use generics with constraints:
// Bad: uses any to avoid figuring out the right type
function processItems(items: any[]): any[] {
return items.filter(item => item.active);
}
// Good: generic with constraint
function processItems<T extends { active: boolean }>(items: T[]): T[] {
return items.filter(item => item.active);
}
// Better: if you just need "has a property", use unknown and narrow
function getProperty(obj: unknown, key: string): unknown {
if (typeof obj === 'object' && obj !== null && key in obj) {
return (obj as Record<string, unknown>)[key];
}
return undefined;
}Fix — Use `unknown` instead of `any` for truly unknown data:
// Bad: any propagates
function parseData(raw: any) {
return raw.user.id; // No error, will crash if shape is wrong
}
// Good: unknown forces you to narrow
function parseData(raw: unknown) {
if (
typeof raw === 'object' &&
raw !== null &&
'user' in raw &&
typeof (raw as any).user === 'object'
) {
// Now you can access safely
}
// Better: use Zod
return UserSchema.parse(raw);
}Shibboleth: any means "I don't know and I don't want TypeScript to check." unknown means "I don't know, but I will check before using." Prefer unknown at API boundaries, never any.
---
Anti-Pattern 2: Over-Engineering Types When Simpler Types Suffice
Novice thinking (advanced TypeScript user): "I can encode this entire business rule in the type system using conditional types and template literals!"
Why wrong: Type complexity has a real cost. Conditional types with multiple infer levels produce error messages that take three minutes to parse. Junior engineers cannot maintain them. IDE autocomplete slows to a crawl. Types that take 200ms to compute create an unpleasant editing experience across the whole file.
The calibration question: Does the type complexity prevent a category of bug that would actually happen? If yes, it's worth it. If you're just proving you can do it, simplify.
Examples of appropriate vs. over-engineered:
// APPROPRIATE: Branded types prevent real mixing bugs
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
// APPROPRIATE: Discriminated union encodes real state machine
type AuthState =
| { status: 'logged_out' }
| { status: 'logging_in' }
| { status: 'logged_in'; user: User; token: string };
// OVER-ENGINEERED: Encoding HTTP method semantics in types
// Does this actually prevent bugs? Probably not.
type HttpMethodHasBody<M extends HttpMethod> =
M extends 'POST' | 'PUT' | 'PATCH' ? true : false;
type RequestWithBody<M extends HttpMethod> =
HttpMethodHasBody<M> extends true
? { method: M; body: unknown }
: { method: M; body?: never };
// OVER-ENGINEERED: Recursive type for deeply nested access
// Causes slow compilation and unreadable errors
type DeepGet<T, Path extends string> =
Path extends `${infer Head}.${infer Tail}`
? Head extends keyof T
? DeepGet<T[Head], Tail>
: never
: Path extends keyof T
? T[Path]
: never;
// Use lodash.get + unknown return type insteadDecision heuristic: If you cannot explain the type in one sentence, it needs a comment. If you need more than two sentences, reconsider whether a simpler approach (runtime validation, a helper function) is more maintainable.
Shibboleth: Expert TypeScript engineers know when NOT to use advanced types. The goal is fewer bugs and better DX, not demonstrating mastery of the type system. Simple union types beat complex conditional types when both achieve the same bug prevention.
Quality Checklist
[ ] No bare `any` types — unknown or generics used instead
[ ] Primitive values that must not be mixed are branded (IDs, money, emails)
[ ] Sum types use discriminated unions, not boolean flags
[ ] External data parsed through Zod schemas (never typed as a known type without validation)
[ ] Switch statements over union types use exhaustive checking
[ ] Conditional types include a comment explaining what they compute
[ ] No type aliases that just rename primitives without branding
[ ] Generic constraints are as specific as needed, no more
[ ] satisfies used for config objects instead of widening assertions
[ ] Type-level tests (expect-type) for complex utility typesOutput Artifacts
1. Domain type module — Branded types for all primitive domain values (IDs, money, emails) 2. Zod schemas — Schema definitions with exported z.infer types 3. Discriminated union definitions — State machines, API results, domain events 4. Type-safe event emitter — Generic EventEmitter with typed events map 5. Utility types — Reusable conditional type utilities for the project
Branded Types Reference
Nominal typing in TypeScript using phantom brand types.
The Core Pattern
TypeScript uses structural typing: two types are compatible if they have the same shape. Branded types add a phantom property that creates nominal compatibility — two brands are never assignable to each other even if the underlying type is the same.
// The brand utility
type Brand<T, B extends string> = T & { readonly __brand: B };
// Never export __brand directly — it's a phantom type marker, not a real fieldBranded Primitives Catalog
Identity Types
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
type ProductId = Brand<string, 'ProductId'>;
type SessionId = Brand<string, 'SessionId'>;
type TransactionId = Brand<string, 'TransactionId'>;
// Constructor pattern — all validation happens here
const UserId = {
from: (id: string): UserId => {
if (!id.startsWith('user_')) throw new Error(`Invalid UserId: ${id}`);
return id as UserId;
},
// fromUUID: no prefix validation needed
generate: (): UserId => `user_${crypto.randomUUID()}` as UserId,
};
// Usage
const uid = UserId.from('user_abc123');
const oid = OrderId.from('order_xyz789');
function getUser(id: UserId): Promise<User> { /* ... */ }
getUser(uid); // OK
getUser(oid); // Error: OrderId is not assignable to UserIdMoney and Numeric Types
// Always store money as integer cents/pence — never floats
type Cents = Brand<number, 'Cents'>;
type USD = Brand<number, 'USD'>; // use Cents internally, USD at display layer
type Percentage = Brand<number, 'Percentage'>;
const Cents = {
from: (n: number): Cents => {
if (!Number.isInteger(n)) throw new Error(`Cents must be integer, got: ${n}`);
if (n < 0) throw new Error(`Cents cannot be negative: ${n}`);
return n as Cents;
},
fromDollars: (dollars: number): Cents => {
return Cents.from(Math.round(dollars * 100));
},
add: (a: Cents, b: Cents): Cents => (a + b) as Cents,
subtract: (a: Cents, b: Cents): Cents => {
if (b > a) throw new Error('Cannot subtract: result would be negative');
return (a - b) as Cents;
},
format: (c: Cents): string => `$${(c / 100).toFixed(2)}`,
};
// Why branded money matters
function applyDiscount(price: Cents, discount: Cents): Cents {
return Cents.subtract(price, discount);
}
// Without branding, easy to pass a raw number or wrong unit
applyDiscount(1000, 10); // Error: not Cents
applyDiscount(Cents.from(1000), Cents.from(10)); // CorrectString-Constrained Types
type EmailAddress = Brand<string, 'EmailAddress'>;
type UrlString = Brand<string, 'UrlString'>;
type HexColor = Brand<string, 'HexColor'>;
type ISODateString = Brand<string, 'ISODateString'>;
const EmailAddress = {
parse: (raw: string): EmailAddress => {
const trimmed = raw.trim().toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
throw new Error(`Invalid email: ${raw}`);
}
return trimmed as EmailAddress;
},
};
const HexColor = {
parse: (raw: string): HexColor => {
if (!/^#[0-9a-fA-F]{6}$/.test(raw)) {
throw new Error(`Invalid hex color: ${raw}`);
}
return raw.toLowerCase() as HexColor;
},
};Path and URL Types
type AbsolutePath = Brand<string, 'AbsolutePath'>;
type RelativePath = Brand<string, 'RelativePath'>;
// Prevents mixing absolute and relative paths in APIs that expect one type
function readFile(path: AbsolutePath): Promise<Buffer> { /* ... */ }
const abs = '/etc/config.json' as AbsolutePath;
const rel = './config.json' as RelativePath;
readFile(abs); // OK
readFile(rel); // Error: RelativePath is not assignable to AbsolutePathZod Integration
When validating external input, Zod transforms raw strings into branded types.
import { z } from 'zod';
// Define Zod schemas that produce branded types
const UserIdSchema = z.string()
.regex(/^user_[a-z0-9]+$/, 'Invalid user ID format')
.transform((s) => s as UserId);
const CentsSchema = z.number()
.int('Must be integer')
.nonnegative('Must be non-negative')
.transform((n) => n as Cents);
const EmailSchema = z.string()
.email()
.transform((s) => s.toLowerCase() as EmailAddress);
// Full schema with branded fields
const CreateOrderSchema = z.object({
userId: UserIdSchema,
productId: z.string().transform(s => s as ProductId),
amountCents: CentsSchema,
email: EmailSchema,
});
type CreateOrderInput = z.infer<typeof CreateOrderSchema>;
// Type: { userId: UserId; productId: ProductId; amountCents: Cents; email: EmailAddress }
// API handler
async function createOrder(rawInput: unknown) {
const input = CreateOrderSchema.parse(rawInput);
// input is now fully typed with brands — no raw strings or numbers
await chargeUser(input.userId, input.amountCents);
}Database Model Patterns
TypeScript database clients (Drizzle, Prisma) return plain strings for ID columns. Use brand construction at the query boundary.
// Drizzle example
import { pgTable, text, integer } from 'drizzle-orm/pg-core';
export const orders = pgTable('orders', {
id: text('id').primaryKey(),
userId: text('user_id').notNull(),
amountCents: integer('amount_cents').notNull(),
});
// Repository pattern — brand at the boundary
class OrderRepository {
async findById(id: OrderId): Promise<Order | null> {
const rows = await db
.select()
.from(orders)
.where(eq(orders.id, id)); // OrderId assignable to string ✓
if (!rows[0]) return null;
return {
id: rows[0].id as OrderId, // Brand at query result boundary
userId: rows[0].userId as UserId,
amountCents: rows[0].amountCents as Cents,
};
}
}Testing Branded Types
// Type-level tests with expect-type
import { expectType, expectError } from 'tsd';
const uid = UserId.from('user_abc');
const oid = OrderId.from('order_xyz');
expectType<UserId>(uid);
expectError<OrderId>(uid); // UserId should not be assignable to OrderId
// Runtime tests
describe('UserId', () => {
it('creates from valid string', () => {
expect(() => UserId.from('user_abc123')).not.toThrow();
});
it('rejects invalid format', () => {
expect(() => UserId.from('abc123')).toThrow('Invalid UserId');
expect(() => UserId.from('')).toThrow();
});
it('generates unique IDs', () => {
const a = UserId.generate();
const b = UserId.generate();
expect(a).not.toBe(b);
expect(a).toMatch(/^user_/);
});
});Common Pitfalls
Pitfall: Sharing brand names across services
// Service A
type UserId = Brand<string, 'UserId'>;
// Service B (in a monorepo)
type UserId = Brand<string, 'UserId'>; // Same brand name!
// These two UserId types ARE mutually assignable because the brand is structural
// Fix: use fully qualified brand names
type UserId = Brand<string, '@payments-service/UserId'>;Pitfall: JSON serialization loses brands
const user = { id: UserId.from('user_abc'), name: 'Alice' };
const json = JSON.stringify(user);
const parsed = JSON.parse(json);
// parsed.id is plain string, not UserId!
// Fix: parse through Zod on deserialization
const parsedUser = UserSchema.parse(JSON.parse(json));
// parsedUser.id is UserId againPitfall: Arithmetic on branded numbers
type Cents = Brand<number, 'Cents'>;
const a = 100 as Cents;
const b = 50 as Cents;
const sum = a + b; // Type is `number`, not `Cents`! Brand is lost in arithmetic
// Fix: use explicit operations that preserve the brand
const Cents = {
add: (a: Cents, b: Cents): Cents => (a + b) as Cents,
multiply: (c: Cents, factor: number): Cents => Math.round(c * factor) as Cents,
};Type-Safe Patterns Reference
Exhaustive checking, builder pattern, type-safe event emitters, and mapped type utilities.
Exhaustive Checking with never
The never type is the bottom type — nothing is assignable to it. This property makes it ideal for exhaustiveness checking.
// The assertNever function
function assertNever(x: never, message?: string): never {
throw new Error(message ?? `Unhandled case: ${JSON.stringify(x)}`);
}
// Pattern 1: Exhaustive switch
type PaymentStatus = 'pending' | 'completed' | 'failed' | 'refunded';
function getStatusMessage(status: PaymentStatus): string {
switch (status) {
case 'pending': return 'Payment is being processed';
case 'completed': return 'Payment successful';
case 'failed': return 'Payment failed — please try again';
case 'refunded': return 'Payment has been refunded';
default: return assertNever(status);
// When 'cancelled' is added to PaymentStatus:
// Error: Argument of type '"cancelled"' is not assignable to 'never'
}
}
// Pattern 2: Exhaustive object lookup (often cleaner than switch)
const STATUS_MESSAGES: Record<PaymentStatus, string> = {
pending: 'Payment is being processed',
completed: 'Payment successful',
failed: 'Payment failed — please try again',
refunded: 'Payment has been refunded',
// Adding 'cancelled' to PaymentStatus causes a compile error here
};
// Pattern 3: Exhaustive if-else chains
function processStatus(status: PaymentStatus): void {
if (status === 'pending') {
/* ... */
} else if (status === 'completed') {
/* ... */
} else if (status === 'failed') {
/* ... */
} else if (status === 'refunded') {
/* ... */
} else {
const _exhaustive: never = status; // Inline exhaustive check
throw new Error(`Unhandled status: ${_exhaustive}`);
}
}Exhaustive Narrowing with Discriminated Unions
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'rectangle'; width: number; height: number }
| { kind: 'triangle'; base: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'rectangle':
return shape.width * shape.height;
case 'triangle':
return (shape.base * shape.height) / 2;
default:
return assertNever(shape);
}
}Type-Safe Event Emitter
Standard Node.js EventEmitter uses string event names and any-typed callbacks. This pattern provides full type safety.
type EventMap = Record<string, any[]>;
class TypedEventEmitter<Events extends EventMap> {
private listeners = new Map<keyof Events, Set<(...args: any[]) => void>>();
on<E extends keyof Events>(
event: E,
listener: (...args: Events[E]) => void
): this {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(listener);
return this;
}
off<E extends keyof Events>(
event: E,
listener: (...args: Events[E]) => void
): this {
this.listeners.get(event)?.delete(listener);
return this;
}
emit<E extends keyof Events>(event: E, ...args: Events[E]): boolean {
const eventListeners = this.listeners.get(event);
if (!eventListeners || eventListeners.size === 0) return false;
eventListeners.forEach(listener => listener(...args));
return true;
}
once<E extends keyof Events>(
event: E,
listener: (...args: Events[E]) => void
): this {
const wrapper = (...args: Events[E]) => {
listener(...args);
this.off(event, wrapper);
};
return this.on(event, wrapper);
}
}
// Usage: define the event map first
interface OrderEvents {
created: [order: Order];
paid: [orderId: OrderId, amountCents: Cents];
shipped: [orderId: OrderId, trackingNumber: string];
cancelled: [orderId: OrderId, reason: string];
}
class OrderService extends TypedEventEmitter<OrderEvents> {
async createOrder(input: CreateOrderInput): Promise<Order> {
const order = await db.orders.create(input);
this.emit('created', order); // Type checked: must pass Order
return order;
}
async cancelOrder(id: OrderId, reason: string): Promise<void> {
await db.orders.update(id, { status: 'cancelled' });
this.emit('cancelled', id, reason); // Type checked
}
}
const service = new OrderService();
service.on('paid', (orderId, amountCents) => {
// orderId is OrderId, amountCents is Cents — fully typed
console.log(`Order ${orderId} paid ${Cents.format(amountCents)}`);
});
// Compile error: wrong argument type
service.on('paid', (orderId, amount: string) => {}); // Error: string is not Cents
service.emit('paid', 'not-an-order-id', 1000); // Error: string is not OrderIdBuilder Pattern with Type-State
The type-state pattern uses generics to track what has been configured, preventing calling .build() before required fields are set.
// Track which fields have been set in the type
type Required = 'set';
type Optional = 'set' | 'unset';
interface QueryBuilderState {
table: Required | 'unset';
conditions: 'set' | 'unset';
}
class QueryBuilder<State extends QueryBuilderState = { table: 'unset'; conditions: 'unset' }> {
private config: { table?: string; conditions?: string[]; limit?: number } = {};
from<T extends string>(table: T): QueryBuilder<{ table: 'set'; conditions: State['conditions'] }> {
this.config.table = table;
return this as any;
}
where(condition: string): QueryBuilder<{ table: State['table']; conditions: 'set' }> {
this.config.conditions = [...(this.config.conditions ?? []), condition];
return this as any;
}
limit(n: number): this {
this.config.limit = n;
return this;
}
// build() only available when table is set
build(this: QueryBuilder<{ table: 'set'; conditions: QueryBuilderState['conditions'] }>): string {
const where = this.config.conditions?.join(' AND ');
const limit = this.config.limit ? ` LIMIT ${this.config.limit}` : '';
return `SELECT * FROM ${this.config.table}${where ? ` WHERE ${where}` : ''}${limit}`;
}
}
const q1 = new QueryBuilder()
.from('orders')
.where('status = pending')
.limit(10)
.build(); // OK
const q2 = new QueryBuilder()
.where('status = pending')
.build(); // Error: build() not available — table not setMapped Type Utilities
DeepReadonly
type DeepReadonly<T> = T extends (infer U)[]
? ReadonlyArray<DeepReadonly<U>>
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
// Useful for configuration objects that should never be mutated
const config = {
database: { host: 'localhost', port: 5432 },
cache: { ttl: 3600 },
} satisfies DeepReadonly<{
database: { host: string; port: number };
cache: { ttl: number };
}>;PickByValue
Select keys from an object type based on value type:
type PickByValue<T, V> = {
[K in keyof T as T[K] extends V ? K : never]: T[K];
};
interface User {
id: string;
name: string;
age: number;
isAdmin: boolean;
createdAt: Date;
}
type StringFields = PickByValue<User, string>;
// { id: string; name: string }
type DateFields = PickByValue<User, Date>;
// { createdAt: Date }RequireAtLeastOne
When you want an object to have at least one of several optional fields:
type RequireAtLeastOne<T, Keys extends keyof T = keyof T> =
Pick<T, Exclude<keyof T, Keys>> &
{
[K in Keys]-?: Required<Pick<T, K>> & Partial<Pick<T, Exclude<Keys, K>>>;
}[Keys];
interface ContactMethod {
email?: string;
phone?: string;
slack?: string;
}
type ContactInput = RequireAtLeastOne<ContactMethod>;
const valid1: ContactInput = { email: 'a@b.com' }; // OK
const valid2: ContactInput = { phone: '555-1234', slack: '@user' }; // OK
const invalid: ContactInput = {}; // Error: at least one requiredPaths (deep key access)
Type-safe key access for deeply nested objects:
type Paths<T, Key extends keyof T = keyof T> =
Key extends string
? T[Key] extends Record<string, any>
? `${Key}` | `${Key}.${Paths<T[Key]>}`
: `${Key}`
: never;
interface Config {
database: {
host: string;
port: number;
credentials: {
user: string;
password: string;
};
};
cache: {
ttl: number;
};
}
type ConfigPaths = Paths<Config>;
// "database" | "database.host" | "database.port" | "database.credentials" |
// "database.credentials.user" | "database.credentials.password" | "cache" | "cache.ttl"
function getConfigValue(config: Config, path: ConfigPaths): unknown {
return path.split('.').reduce((obj: any, key) => obj?.[key], config);
}
getConfigValue(config, 'database.port'); // OK
getConfigValue(config, 'database.wrong'); // Error: not a valid pathFunction Overloads for Conditional Return Types
When the return type depends on input parameters:
// Overload signatures
function parseValue(value: string): string;
function parseValue(value: number): number;
function parseValue(value: boolean): boolean;
function parseValue(value: string | number | boolean): string | number | boolean;
// Implementation (single, handles all cases)
function parseValue(value: string | number | boolean): string | number | boolean {
return value;
}
// Callers get the specific return type
const s = parseValue('hello'); // string
const n = parseValue(42); // number
const b = parseValue(true); // boolean
// More practical: conditional return based on options
function query(sql: string, options: { single: true }): Promise<Row>;
function query(sql: string, options?: { single?: false }): Promise<Row[]>;
function query(sql: string, options?: { single?: boolean }): Promise<Row | Row[]> {
// implementation
}
const row = await query('SELECT * FROM users WHERE id = 1', { single: true });
// row is Row, not Row[]
const rows = await query('SELECT * FROM users');
// rows is Row[]const Assertions and Literal Types
// Without as const: widened types
const directions = ['north', 'south', 'east', 'west'];
// Type: string[]
type Direction = typeof directions[number];
// Type: string ← not useful
// With as const: literal types preserved
const DIRECTIONS = ['north', 'south', 'east', 'west'] as const;
// Type: readonly ['north', 'south', 'east', 'west']
type Direction = typeof DIRECTIONS[number];
// Type: 'north' | 'south' | 'east' | 'west' ← useful!
// Config objects with as const
const HTTP_METHODS = {
GET: 'GET',
POST: 'POST',
PUT: 'PUT',
DELETE: 'DELETE',
} as const;
type HttpMethod = typeof HTTP_METHODS[keyof typeof HTTP_METHODS];
// Type: 'GET' | 'POST' | 'PUT' | 'DELETE'
// Enum alternative pattern (more idiomatic than TS enums)
const Role = {
Admin: 'admin',
User: 'user',
Moderator: 'moderator',
} as const;
type Role = typeof Role[keyof typeof Role];
// Type: 'admin' | 'user' | 'moderator'
// Usage: Role.Admin === 'admin' (no need for Role["Admin"])Type-Level Testing with tsd/expect-type
Test that your utility types produce the correct types:
// Install: npm install -D tsd
// In a .test-d.ts file:
import { expectType, expectError, expectAssignable } from 'tsd';
import type { DeepReadonly, PickByValue, RequireAtLeastOne } from './utils';
// Test DeepReadonly
type Config = DeepReadonly<{ db: { host: string; port: number } }>;
declare const config: Config;
expectType<string>(config.db.host);
expectError(config.db.host = 'new-host'); // Should error: readonly
// Test branded types
declare const userId: UserId;
declare const orderId: OrderId;
expectAssignable<string>(userId); // UserId extends string
expectError<UserId>(orderId); // OrderId not assignable to UserId