TypeScript's type system is Turing complete. This means an enormous amount of expressive power is available directly in .ts files, and most codebases use only a fraction of it. The patterns documented below address real categories of bugs that occur in production systems. Each section shows the problem, the solution, and the type-level mechanics behind it.
Branded Types
The Problem
Consider a function that transfers credits between users:
async function transferCredits(
fromUser: string,
toUser: string,
amount: number
) {
await db.credits.decrement(fromUser, amount);
await db.credits.increment(toUser, amount);
await audit.log({ fromUser, toUser, amount });
}All ID parameters are typed as string. Passing a UserId where an OrderId is expected compiles without error unless branded types are used:
// Compiles. Decrements credits from a nonexistent row.
await transferCredits(order.id, user.id, order.creditAmount);The type system treats all string values as interchangeable. The bug passes type checking, linting, and code review. It surfaces only when someone inspects the database.
The Solution
Branded types attach a phantom type tag to a primitive, making structurally identical types incompatible at compile time:
// Brand utility type.
// The __brand property exists only at the type level.
// It is erased during compilation and has zero runtime cost.
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
type Email = Brand<string, 'Email'>;
type Currency = Brand<number, 'Currency'>;The type hierarchy:
string
/ \
/ \
UserId OrderId
(string & (string &
{__brand: {__brand:
'UserId'}) 'OrderId'})
UserId <: string UserId can be passed where string is expected.
string !<: UserId A plain string cannot be passed where UserId is expected.
UserId !<: OrderId UserId and OrderId are mutually incompatible.
Constructor functions serve as the single validated entry point from unbranded to branded values:
// Validate format, then cast exactly once.
function createUserId(id: string): UserId {
if (!id.match(/^usr_[a-z0-9]{16}$/)) {
throw new Error(`Invalid user ID format: ${id}`);
}
return id as UserId;
}
function createOrderId(id: string): OrderId {
if (!id.match(/^ord_[a-z0-9]{12}$/)) {
throw new Error(`Invalid order ID format: ${id}`);
}
return id as OrderId;
}
function createEmail(email: string): Email {
if (!email.includes('@') || !email.includes('.')) {
throw new Error(`Invalid email: ${email}`);
}
return email as Email;
}
// For numeric brands, validation can enforce business rules.
function createCurrency(amount: number): Currency {
if (!Number.isFinite(amount) || amount < 0) {
throw new Error(`Invalid currency amount: ${amount}`);
}
// Round to 2 decimal places to avoid floating-point drift.
return Math.round(amount * 100) / 100 as Currency;
}The original function becomes:
async function transferCredits(
fromUser: UserId,
toUser: UserId,
amount: Currency
) {
await db.credits.decrement(fromUser, amount);
await db.credits.increment(toUser, amount);
await audit.log({ fromUser, toUser, amount });
}The accidental call site now fails at compile time:
const orderId = createOrderId('ord_xyz789abc123');
const userId = createUserId('usr_abc123def456ghij');
transferCredits(orderId, userId, 500);
// ^^^^^^^
// Error: Argument of type 'OrderId' is not assignable
// to parameter of type 'UserId'.Why Not Use Wrapper Classes?
A class like class UserId { constructor(public value: string) {} } achieves similar compile-time safety but introduces runtime overhead: object allocation, JSON serialization complications, inability to use the value directly as an object key, and equality comparison requiring custom logic. Branded types provide equivalent safety with no runtime representation change.
Branded Types for Domain Constraints
Brands are not limited to ID types. They can encode any domain invariant:
type NonEmptyString = Brand<string, 'NonEmpty'>;
type PositiveInt = Brand<number, 'PositiveInt'>;
type NormalizedEmail = Brand<string, 'NormalizedEmail'>;
type SanitizedHtml = Brand<string, 'SanitizedHtml'>;
type PercentageValue = Brand<number, 'Percentage'>; // 0..100
function normalizeEmail(raw: string): NormalizedEmail {
const normalized = raw.trim().toLowerCase();
if (!normalized.includes('@')) {
throw new Error(`Invalid email: ${raw}`);
}
return normalized as NormalizedEmail;
}
// A function that sends email can require NormalizedEmail,
// guaranteeing the input has been validated and normalized.
function sendEmail(to: NormalizedEmail, subject: string, body: string): void {
// ...
}Conditional Types and Type-Level Programming
Basic Conditional Types
Conditional types follow ternary syntax at the type level:
// Evaluates to true or false based on whether T extends string.
type IsString<T> = T extends string ? true : false;
type A = IsString<'hello'>; // true
type B = IsString<42>; // false
type C = IsString<string>; // trueThe infer Keyword
infer enables pattern matching and extraction within conditional types. It captures a type variable from a structural pattern:
// Extract the resolved type from a Promise.
type Awaited<T> = T extends Promise<infer R> ? R : T;
type X = Awaited<Promise<string>>; // string
type Y = Awaited<Promise<Promise<number>>>; // Promise<number>
// (one level only)
// Extract the return type of an async function.
type AsyncReturnType<T extends (...args: any[]) => Promise<any>> =
T extends (...args: any[]) => Promise<infer R> ? R : never;
async function fetchUser() {
return { id: '1', name: 'Alice', role: 'admin' as const };
}
type User = AsyncReturnType<typeof fetchUser>;
// { id: string; name: string; role: 'admin' }Recursive Conditional Types
Conditional types can be recursive. DeepPartial is a common example:
// Standard Partial<T> only makes top-level properties optional.
// DeepPartial<T> recurses through all nested objects.
type DeepPartial<T> = T extends object
? { [P in keyof T]?: DeepPartial<T[P]> }
: T;Applied to a nested config type:
interface DatabaseConfig {
host: string;
port: number;
credentials: {
username: string;
password: string;
};
pool: {
min: number;
max: number;
idleTimeoutMs: number;
};
}
interface AppConfig {
database: DatabaseConfig;
cache: {
ttl: number;
maxSize: number;
strategy: 'lru' | 'lfu';
};
}The recursion expands as follows:
DeepPartial<AppConfig>
|
+-- database?: DeepPartial<DatabaseConfig>
| |
| +-- host?: string (base case: primitive)
| +-- port?: number (base case: primitive)
| +-- credentials?: DeepPartial<{username, password}>
| | +-- username?: string
| | +-- password?: string
| +-- pool?: DeepPartial<{min, max, idleTimeoutMs}>
| +-- min?: number
| +-- max?: number
| +-- idleTimeoutMs?: number
|
+-- cache?: DeepPartial<{ttl, maxSize, strategy}>
+-- ttl?: number
+-- maxSize?: number
+-- strategy?: 'lru' | 'lfu'
This allows partial config overrides at any depth:
function mergeConfig(
base: AppConfig,
overrides: DeepPartial<AppConfig>
): AppConfig {
// deep merge implementation
}
// Only override pool.max. Everything else retains the base value.
mergeConfig(defaultConfig, { database: { pool: { max: 200 } } });TypeScript imposes a recursion depth limit of approximately 50 levels. Exceeding it produces "Type instantiation is excessively deep and possibly infinite."
Distributive Conditional Types
When a conditional type receives a union as its type parameter, TypeScript distributes the condition over each union member individually:
type ToArray<T> = T extends any ? T[] : never;
type Result = ToArray<string | number>;
// Evaluates to: string[] | number[]
// NOT: (string | number)[]The evaluation proceeds as:
ToArray<string | number>
|
distributes to:
|
ToArray<string> | ToArray<number>
| |
string[] number[]
| |
+--------+----------+
|
string[] | number[]
To suppress distribution, wrap both sides in a tuple:
type ToArrayNonDist<T> = [T] extends [any] ? T[] : never;
type Result2 = ToArrayNonDist<string | number>;
// (string | number)[]The tuple wrapper makes T non-naked, preventing distribution.
Type-Level Computation: Arithmetic
TypeScript's type system can perform limited arithmetic through tuple manipulation:
// Build a tuple of length N.
type BuildTuple<N extends number, T extends any[] = []> =
T['length'] extends N ? T : BuildTuple<N, [...T, any]>;
// Add two numbers at the type level.
type Add<A extends number, B extends number> =
[...BuildTuple<A>, ...BuildTuple<B>]['length'];
type Sum = Add<3, 4>; // 7
// Subtract (A - B, where A >= B).
type Subtract<A extends number, B extends number> =
BuildTuple<A> extends [...BuildTuple<B>, ...infer Rest]
? Rest['length']
: never;
type Diff = Subtract<10, 3>; // 7This technique is used in libraries that encode array lengths or index bounds in the type system. Practical limits apply: tuple sizes above roughly 1000 elements cause compiler performance degradation.
Discriminated Unions
The Problem: Boolean State Flags
A common anti-pattern represents state as a flat object with boolean flags and optional fields:
interface RequestState {
isLoading: boolean;
isError: boolean;
data?: unknown;
error?: Error;
startedAt?: number;
completedAt?: number;
retryCount?: number;
}This type permits invalid combinations:
// Loading and errored simultaneously. data present during loading.
// The type system accepts all of this.
const invalid: RequestState = {
isLoading: true,
isError: true,
data: 'stale',
error: new Error('failure'),
};The relationship between fields is not encoded. isError: true should guarantee that error exists and data does not, but the type does not enforce this.
The Solution: Tagged Union
A discriminated union enumerates every valid state explicitly:
type RequestState =
| { status: 'idle' }
| { status: 'loading'; startedAt: number }
| { status: 'success'; data: unknown; completedAt: number }
| { status: 'error'; error: Error; retryCount: number };The status field is the discriminant. Each variant carries exactly the fields relevant to that state:
+---------------------------+ +-------------------------------+
| VALID STATES | | INVALID STATES |
| | | (cannot be constructed) |
| idle: | | |
| no additional fields | | loading + error data NO |
| | | success without data NO |
| loading: | | error without Error obj NO |
| startedAt | | data during loading NO |
| | | retryCount on success NO |
| success: | | |
| data, completedAt | +-------------------------------+
| |
| error: |
| error, retryCount |
+---------------------------+
Type Narrowing in Switch Statements
TypeScript narrows the type within each case branch:
function handleState(state: RequestState): string {
switch (state.status) {
case 'idle':
// state: { status: 'idle' }
return 'Ready';
case 'loading':
// state: { status: 'loading'; startedAt: number }
return `Loading since ${state.startedAt}`;
case 'success':
// state: { status: 'success'; data: unknown; completedAt: number }
return `Received ${JSON.stringify(state.data)}`;
case 'error':
// state: { status: 'error'; error: Error; retryCount: number }
return `Failed: ${state.error.message} (attempt ${state.retryCount})`;
}
}The narrowing flow:
state: RequestState (full union of 4 variants)
|
switch (state.status)
|
+---------+---------+-----------+
| | | |
'idle' 'loading' 'success' 'error'
| | | |
narrowed narrowed narrowed narrowed
to idle to loading to success to error
variant variant variant variant
| | | |
.data? .startedAt .data .error
NO YES YES YES
.error? .data? .error? .data?
NO NO NO NO
Exhaustiveness Checking
The assertNever pattern produces a compile error if a new variant is added to the union but not handled:
function assertNever(x: never): never {
throw new Error(`Unhandled variant: ${JSON.stringify(x)}`);
}
function handleState(state: RequestState): string {
switch (state.status) {
case 'idle': return 'Ready';
case 'loading': return 'Loading';
case 'success': return 'Done';
case 'error': return 'Failed';
default: return assertNever(state);
// ^^^^^^^^^^^^
// If a fifth variant is added to RequestState
// and this switch does not handle it, state
// will not be 'never' here, causing a compile error.
}
}Discriminated Unions for Domain Events
This pattern extends naturally to event systems:
type DomainEvent =
| { type: 'USER_CREATED'; userId: UserId; email: Email; timestamp: number }
| { type: 'ORDER_PLACED'; orderId: OrderId; items: LineItem[]; total: Currency }
| { type: 'PAYMENT_FAILED'; orderId: OrderId; reason: string; retryable: boolean }
| { type: 'ITEM_SHIPPED'; orderId: OrderId; trackingNumber: string };
function processEvent(event: DomainEvent): void {
switch (event.type) {
case 'USER_CREATED':
// event.userId and event.email are available and correctly typed.
sendWelcomeEmail(event.email);
break;
case 'ORDER_PLACED':
// event.items and event.total are available.
reserveInventory(event.items);
break;
case 'PAYMENT_FAILED':
if (event.retryable) scheduleRetry(event.orderId);
break;
case 'ITEM_SHIPPED':
notifyCustomer(event.orderId, event.trackingNumber);
break;
default:
assertNever(event);
}
}Mapped Types and Type Transformations
Fundamentals
Mapped types iterate over keys of an existing type and produce a new type:
// Built-in Partial: makes every property optional.
type Partial<T> = { [K in keyof T]?: T[K] };
// Built-in Required: removes the optional modifier.
type Required<T> = { [K in keyof T]-?: T[K] };
// Built-in Readonly: adds readonly to every property.
type Readonly<T> = { readonly [K in keyof T]: T[K] };Selective Transformations
Require specific fields while keeping the rest optional:
type WithRequired<T, K extends keyof T> =
Omit<T, K> & Required<Pick<T, K>>;
interface UserUpdate {
name?: string;
email?: string;
role?: 'admin' | 'user';
avatar?: string;
}
// email is required. All other fields remain optional.
type UserUpdateWithEmail = WithRequired<UserUpdate, 'email'>;
function updateUser(updates: UserUpdateWithEmail) {
// updates.email is guaranteed to be string.
// No runtime check needed.
sendVerification(updates.email);
}Make specific fields readonly while keeping others mutable:
type WithReadonly<T, K extends keyof T> =
Omit<T, K> & Readonly<Pick<T, K>>;
// id and createdAt are readonly. Other fields remain mutable.
type User = WithReadonly<{
id: string;
createdAt: Date;
name: string;
email: string;
}, 'id' | 'createdAt'>;Key Remapping with as
TypeScript 4.1 introduced the as clause in mapped types for transforming keys:
// Generate getter methods for every property.
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
};
// Generate setter methods for every property.
type Setters<T> = {
[K in keyof T as `set${Capitalize<string & K>}`]: (value: T[K]) => void
};
interface Person {
name: string;
age: number;
location: string;
}
type PersonGetters = Getters<Person>;
// {
// getName: () => string;
// getAge: () => number;
// getLocation: () => string;
// }
type PersonSetters = Setters<Person>;
// {
// setName: (value: string) => void;
// setAge: (value: number) => void;
// setLocation: (value: string) => void;
// }Filtering Keys
Remapping a key to never removes it from the resulting type:
// Retain only properties whose values are functions.
type MethodsOnly<T> = {
[K in keyof T as T[K] extends Function ? K : never]: T[K]
};
// Retain only properties whose values are NOT functions.
type PropertiesOnly<T> = {
[K in keyof T as T[K] extends Function ? never : K]: T[K]
};
interface ApiClient {
baseUrl: string;
timeout: number;
get(url: string): Promise<Response>;
post(url: string, body: unknown): Promise<Response>;
delete(url: string): Promise<Response>;
}
type ApiMethods = MethodsOnly<ApiClient>;
// {
// get(url: string): Promise<Response>;
// post(url: string, body: unknown): Promise<Response>;
// delete(url: string): Promise<Response>;
// }
type ApiConfig = PropertiesOnly<ApiClient>;
// {
// baseUrl: string;
// timeout: number;
// }Deep Readonly
A recursive mapped type that makes an entire object tree immutable:
type DeepReadonly<T> =
T extends Function
? T
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
// Every nested property becomes readonly.
// Attempting to mutate any field at any depth produces a compile error.
type FrozenConfig = DeepReadonly<AppConfig>;Template Literal Types
Constructing String Types
Template literal types apply string interpolation at the type level:
type EventName = 'click' | 'focus' | 'blur';
type Handler = `on${Capitalize<EventName>}`;
// 'onClick' | 'onFocus' | 'onBlur'
type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type Endpoint = '/users' | '/orders' | '/products';
type Route = `${HTTPMethod} ${Endpoint}`;
// 'GET /users' | 'GET /orders' | 'GET /products'
// | 'POST /users' | 'POST /orders' | 'POST /products'
// | 'PUT /users' | ... (4 x 3 = 12 combinations)Type-Safe CSS Values
type CSSUnit = 'px' | 'rem' | 'em' | '%' | 'vh' | 'vw';
type CSSValue = `${number}${CSSUnit}`;
function setWidth(el: HTMLElement, width: CSSValue): void {
el.style.width = width;
}
setWidth(document.body, '100px'); // OK
setWidth(document.body, '2.5rem'); // OK
setWidth(document.body, '100'); // Error: not assignable to CSSValue
setWidth(document.body, 'wide'); // Error: not assignable to CSSValueParsing with infer
Template literal types combined with infer can extract substrings at the type level. A type-safe route parameter extractor:
// Recursively extract colon-prefixed segments from a path string.
type ExtractParams<T extends string> =
T extends `${infer _Start}:${infer Param}/${infer Rest}`
? { [K in Param | keyof ExtractParams<Rest>]: string }
: T extends `${infer _Start}:${infer Param}`
? { [K in Param]: string }
: {};
type UserPostParams = ExtractParams<'/users/:userId/posts/:postId'>;
// { userId: string; postId: string }
type ProfileParams = ExtractParams<'/profile/:username'>;
// { username: string }
type StaticRoute = ExtractParams<'/about'>;
// {}Step-by-step evaluation of ExtractParams<'/users/:userId/posts/:postId'>:
ExtractParams<'/users/:userId/posts/:postId'>
Step 1: Match pattern ${_Start}:${Param}/${Rest}
_Start = '/users/'
Param = 'userId'
Rest = 'posts/:postId'
Result = { userId: string } & ExtractParams<'posts/:postId'>
Step 2: ExtractParams<'posts/:postId'>
Does NOT match first pattern (no / after param)
Matches second pattern: ${_Start}:${Param}
_Start = 'posts/'
Param = 'postId'
Result = { postId: string }
Final: { userId: string } & { postId: string }
=> { userId: string; postId: string }
Type-Safe Event Emitter Using Template Literals
type EventMap = {
userCreated: { userId: string; email: string };
orderPlaced: { orderId: string; total: number };
pageViewed: { path: string; referrer: string };
};
type EventHandler<T> = (payload: T) => void;
class TypedEmitter<Events extends Record<string, any>> {
private handlers = new Map<string, Function[]>();
on<K extends string & keyof Events>(
event: K,
handler: EventHandler<Events[K]>
): void {
const list = this.handlers.get(event) ?? [];
list.push(handler);
this.handlers.set(event, list);
}
emit<K extends string & keyof Events>(
event: K,
payload: Events[K]
): void {
const list = this.handlers.get(event) ?? [];
list.forEach(fn => fn(payload));
}
}
const emitter = new TypedEmitter<EventMap>();
// Payload type is inferred from the event name.
emitter.on('userCreated', (payload) => {
// payload: { userId: string; email: string }
console.log(payload.userId);
});
// Error: property 'total' does not exist on type { userId: string; email: string }
emitter.on('userCreated', (payload) => {
console.log(payload.total);
});The Builder Pattern with Type-Level State Tracking
The Problem: Runtime Validation of Required Fields
A builder that relies on runtime checks to verify required fields have been set:
class UnsafeQueryBuilder {
private config: Record<string, unknown> = {};
host(h: string) { this.config.host = h; return this; }
port(p: number) { this.config.port = p; return this; }
database(db: string){ this.config.database = db; return this; }
username(u: string) { this.config.username = u; return this; }
build(): DatabaseConnection {
if (!this.config.host || !this.config.port || !this.config.database) {
throw new Error('Missing required fields');
}
return new DatabaseConnection(this.config);
}
}
// Compiles. Throws at runtime.
new UnsafeQueryBuilder().username('admin').build();The Solution: Phantom Type Parameter Tracking
The builder's generic parameter accumulates the names of fields that have been set. The build method is constrained to only be callable when all required fields are present:
type RequiredFields = 'host' | 'port' | 'database';
class QueryBuilder<Provided extends string = never> {
private config: Record<string, unknown> = {};
host(h: string): QueryBuilder<Provided | 'host'> {
this.config.host = h;
return this as any;
}
port(p: number): QueryBuilder<Provided | 'port'> {
this.config.port = p;
return this as any;
}
database(db: string): QueryBuilder<Provided | 'database'> {
this.config.database = db;
return this as any;
}
username(u: string): QueryBuilder<Provided | 'username'> {
this.config.username = u;
return this as any;
}
// The 'this' parameter type constrains when build() is callable.
// It requires Provided to be a superset of RequiredFields.
build(
this: QueryBuilder<RequiredFields>
): DatabaseConnection {
return new DatabaseConnection(this.config);
}
}The type parameter evolves as methods are chained:
new QueryBuilder() QueryBuilder<never>
.host('localhost') QueryBuilder<'host'>
.port(5432) QueryBuilder<'host' | 'port'>
.database('mydb') QueryBuilder<'host' | 'port' | 'database'>
.build() OK. RequiredFields satisfied.
new QueryBuilder() QueryBuilder<never>
.host('localhost') QueryBuilder<'host'>
.port(5432) QueryBuilder<'host' | 'port'>
.build()
// Error: The 'this' context of type 'QueryBuilder<"host" | "port">'
// is not assignable to method's 'this' of type
// 'QueryBuilder<"host" | "port" | "database">'.
// Property 'database' is missing.
The as any casts are internal implementation details. The external API is fully type-safe. Consumers cannot call build() without providing all required fields.
Builder Pattern for HTTP Request Configuration
A more elaborate example showing how the builder can enforce ordering constraints:
type HTTPBuilderState = {
hasMethod: boolean;
hasUrl: boolean;
};
class RequestBuilder<
State extends HTTPBuilderState = { hasMethod: false; hasUrl: false }
> {
private config: Record<string, unknown> = {};
method(
m: 'GET' | 'POST' | 'PUT' | 'DELETE'
): RequestBuilder<State & { hasMethod: true }> {
this.config.method = m;
return this as any;
}
url(u: string): RequestBuilder<State & { hasUrl: true }> {
this.config.url = u;
return this as any;
}
header(key: string, value: string): RequestBuilder<State> {
// Headers are optional. State does not change.
const headers = (this.config.headers as Record<string, string>) ?? {};
headers[key] = value;
this.config.headers = headers;
return this as any;
}
// Both method and url must be set before send() is available.
send(
this: RequestBuilder<{ hasMethod: true; hasUrl: true }>
): Promise<Response> {
return fetch(this.config.url as string, this.config);
}
}
// OK: both method and url are set.
new RequestBuilder()
.method('GET')
.url('https://api.example.com/users')
.header('Authorization', 'Bearer token')
.send();
// Error: 'url' has not been called.
new RequestBuilder()
.method('GET')
.send();Variance
Definition
Variance describes how subtyping relationships between generic types relate to subtyping relationships of their type parameters.
Given: Cat extends Animal
Covariant (out T): Producer<Cat> <: Producer<Animal>
Same direction as the subtyping relationship.
Contravariant (in T): Consumer<Animal> <: Consumer<Cat>
Reversed direction.
Invariant (in out T): ReadWrite<Cat> is NOT related to ReadWrite<Animal>.
No subtyping relationship in either direction.
TypeScript Syntax
TypeScript 4.7 introduced in and out annotations:
// T appears only in output (return) positions.
type Producer<out T> = {
get(): T;
};
// T appears only in input (parameter) positions.
type Consumer<in T> = {
accept(value: T): void;
};
// T appears in both positions.
type ReadWrite<in out T> = {
get(): T;
set(value: T): void;
};Practical Impact
interface Animal { name: string }
interface Cat extends Animal { purr(): void }
interface Dog extends Animal { bark(): void }
// Covariant: Producer<Cat> is assignable to Producer<Animal>
// because anything that produces Cats also produces Animals.
declare let catProducer: Producer<Cat>;
declare let animalProducer: Producer<Animal>;
animalProducer = catProducer; // OK
catProducer = animalProducer; // Error
// Contravariant: Consumer<Animal> is assignable to Consumer<Cat>
// because a function that handles any Animal can handle a Cat.
declare let catConsumer: Consumer<Cat>;
declare let animalConsumer: Consumer<Animal>;
catConsumer = animalConsumer; // OK
animalConsumer = catConsumer; // ErrorDiagram of the variance relationships:
Type hierarchy: Covariant Contravariant
Animal Producer<Animal> Consumer<Cat>
| | |
v v v
Cat Producer<Cat> Consumer<Animal>
(Cat extends Animal) (same direction) (reversed direction)
Why Variance Matters
Function parameter types are contravariant (with strictFunctionTypes enabled). This is a common source of confusing type errors in callback-heavy code:
type AnimalHandler = (a: Animal) => void;
type CatHandler = (c: Cat) => void;
// AnimalHandler is assignable to CatHandler (contravariance).
// A function that handles any Animal can safely handle a Cat.
const handleAnimal: AnimalHandler = (a) => console.log(a.name);
const handleCat: CatHandler = handleAnimal; // OK
// CatHandler is NOT assignable to AnimalHandler.
// A function expecting a Cat might call .purr(), which
// does not exist on a generic Animal.
const catOnly: CatHandler = (c) => c.purr();
const handleAny: AnimalHandler = catOnly; // ErrorConst Assertions and Deriving Types from Values
The Problem: Type Widening
Without as const, TypeScript widens literal values to their base types:
const routes = {
home: '/',
about: '/about',
blog: '/blog',
post: '/blog/:slug',
};
// Type: { home: string; about: string; blog: string; post: string }
// The literal values are lost.The Solution: as const
const routes = {
home: '/',
about: '/about',
blog: '/blog',
post: '/blog/:slug',
} as const;
// Type: {
// readonly home: '/';
// readonly about: '/about';
// readonly blog: '/blog';
// readonly post: '/blog/:slug';
// }Types can now be derived from the value:
// Union of all route paths.
type RoutePath = typeof routes[keyof typeof routes];
// '/' | '/about' | '/blog' | '/blog/:slug'
// Union of all route names.
type RouteName = keyof typeof routes;
// 'home' | 'about' | 'blog' | 'post'Single Source of Truth Pattern
This eliminates the need to maintain parallel type and value definitions:
const HTTP_STATUS = {
OK: 200,
CREATED: 201,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
INTERNAL_SERVER_ERROR: 500,
} as const;
// Union of the literal status code numbers.
type StatusCode = typeof HTTP_STATUS[keyof typeof HTTP_STATUS];
// 200 | 201 | 400 | 401 | 403 | 404 | 500
// Union of the status name strings.
type StatusName = keyof typeof HTTP_STATUS;
// 'OK' | 'CREATED' | 'BAD_REQUEST' | ...const PERMISSIONS = ['read', 'write', 'delete', 'admin'] as const;
// Derive the union type from the array.
type Permission = typeof PERMISSIONS[number];
// 'read' | 'write' | 'delete' | 'admin'
// The array can be used for runtime iteration,
// and the type can be used for compile-time checking.
function hasPermission(
userPerms: Permission[],
required: Permission
): boolean {
return userPerms.includes(required);
}Combining Patterns: Type-Safe API Client
The following example combines branded types, template literal types, conditional types, mapped types, and const assertions into a type-safe API client:
// 1. Define endpoints as a const object.
// Each key encodes the HTTP method and path.
// Each value defines the request/response shape.
const endpoints = {
'GET /users': {} as {
response: User[];
},
'GET /users/:id': {} as {
params: { id: UserId };
response: User;
},
'POST /users': {} as {
body: { name: string; email: Email };
response: User;
},
'PUT /users/:id': {} as {
params: { id: UserId };
body: Partial<{ name: string; email: Email }>;
response: User;
},
'DELETE /users/:id': {} as {
params: { id: UserId };
response: void;
},
} as const;
type Endpoints = typeof endpoints;
// 2. Extract the HTTP method from an endpoint key.
type ExtractMethod<T extends string> =
T extends `${infer M} ${string}` ? M : never;
// 3. Extract the path from an endpoint key.
type ExtractPath<T extends string> =
T extends `${string} ${infer P}` ? P : never;
// 4. Filter endpoints by method.
type EndpointsForMethod<M extends string> = {
[K in keyof Endpoints as ExtractMethod<K & string> extends M
? K
: never
]: Endpoints[K]
};
// 5. Build the request function type.
// Params and body are required only if defined in the endpoint spec.
type RequestArgs<Spec> =
(Spec extends { params: infer P } ? { params: P } : {}) &
(Spec extends { body: infer B } ? { body: B } : {});
type ResponseType<Spec> =
Spec extends { response: infer R } ? R : never;
// 6. The type-safe request function.
async function apiRequest<K extends keyof Endpoints>(
endpoint: K,
args: RequestArgs<Endpoints[K]>
): Promise<ResponseType<Endpoints[K]>> {
// Implementation: parse method and path from endpoint string,
// substitute params, serialize body, make HTTP request.
// The implementation details are orthogonal to the type safety.
throw new Error('Not implemented');
}
// 7. Usage. Types are fully inferred.
const user = await apiRequest('GET /users/:id', {
params: { id: createUserId('usr_abc123def456ghij') },
});
// user: User
const users = await apiRequest('GET /users', {});
// users: User[]
await apiRequest('POST /users', {
body: {
name: 'Alice',
email: createEmail('alice@example.com'),
},
});
// Error: property 'params' is missing.
await apiRequest('DELETE /users/:id', {});
// Error: 'PATCH /users' does not exist in Endpoints.
await apiRequest('PATCH /users', {});Adding a new endpoint requires only a single new entry in the endpoints object. All request functions, parameter types, and response types update automatically.
Type Inference Boundaries
TypeScript's type inference is powerful but has documented limits.
Variadic Function Composition
A generic pipe function with two arguments infers correctly:
function pipe<A, B, C>(
a: A,
fn1: (a: A) => B,
fn2: (b: B) => C
): C {
return fn2(fn1(a));
}
const result = pipe(
'hello',
(s) => s.length, // s: string (inferred)
(n) => n.toFixed(2) // n: number (inferred)
);Making pipe accept an arbitrary number of functions requires recursive conditional types that frequently exceed the compiler's instantiation depth limit. Libraries such as fp-ts and Effect use function overloads for arities 2 through 9 with a fallback for larger arities.
Recursion Depth
The compiler limits recursive type instantiation to approximately 50 levels. Types that exceed this produce the error "Type instantiation is excessively deep and possibly infinite." Strategies for working within this limit:
- Use overloads for common cases instead of a single recursive type.
- Break deep recursions into smaller utility types composed together.
- Accept a strategic
anyin the implementation while keeping the public API typed.
Circular References
Directly recursive type aliases are supported for certain patterns:
// This works (since TypeScript 3.7).
type Json =
| string
| number
| boolean
| null
| Json[]
| { [key: string]: Json };More complex circular structures may require introducing an intermediate interface to break the cycle.
Performance Guideline
If the editor's language server becomes noticeably slow (multi-second delays on hover or completion), the type computation is too expensive. Simplify the types, reduce recursion depth, or use overloads.
Summary of Patterns
+----------------------+----------------------------------------------+
| Pattern | Problem it solves |
+----------------------+----------------------------------------------+
| Branded types | Primitive type confusion (UserId vs OrderId) |
| Conditional types | Type-level logic and extraction |
| Discriminated unions | Invalid state combinations |
| Mapped types | Bulk type transformations |
| Template literals | String-level type safety |
| Builder pattern | Missing required configuration fields |
| Variance annotations | Incorrect generic subtyping |
| Const assertions | Type widening, duplicate definitions |
+----------------------+----------------------------------------------+
Each pattern encodes domain invariants into the type system, converting runtime errors into compile-time errors. The upfront cost of writing more precise types is recovered through eliminated bug categories and increased refactoring confidence.