Advanced TypeScript Type Patterns
Advanced type patterns enable you to encode complex business rules directly in the type system, preventing entire classes of bugs at compile time. Use mapped types to transform existing types systematically, conditional types for type-level logic, template literals for type-safe strings, and discriminated unions for state machines. These patterns make illegal states unrepresentable, turning runtime errors into compile-time errors. However, prioritize readability - if a type becomes incomprehensible to your team, simplify it or add thorough documentation. The goal is safety without sacrificing maintainability.
Overview
This guide covers advanced TypeScript type patterns for modeling complex domains, API contracts, and type-safe state management. You'll learn how to use mapped types to transform types systematically, conditional types for type-level branching, template literals for type-safe strings, and recursive types for nested structures. These techniques move beyond basic type annotations to leverage TypeScript's full power - encoding business invariants in types, building type-safe APIs, and creating self-documenting code. These patterns build on TypeScript General fundamentals.
Core Principles
- Encode Business Rules in Types: Use the type system to prevent invalid states
- Type Inference Over Explicit Types: Let TypeScript infer when possible
- Composable Types: Build complex types from simple, reusable pieces
- Exhaustiveness Checking: Use
neverto catch unhandled cases at compile time - Generic Constraints: Make generic types more specific and useful
Mapped Types
Basic Mapped Types
Mapped types transform one type into another by iterating over properties. They're TypeScript's equivalent of Array.map() but for types. Instead of transforming values, you're transforming type properties.
// Make all properties optional
// [P in keyof T] iterates over each property P in T
// The ? makes each property optional
type Partial<T> = {
[P in keyof T]?: T[P];
};
// Example: Partial<{ name: string; age: number }> → { name?: string; age?: number }
// Make all properties required
// -? removes the optional modifier
type Required<T> = {
[P in keyof T]-?: T[P];
};
// Example: Required<{ name?: string }> → { name: string }
// Make all properties readonly
// readonly modifier prevents reassignment
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};
// Example: Readonly<{ name: string }> → { readonly name: string }
// Pick specific properties
// K extends keyof T constrains K to be a key of T
// Creates new type with only the specified properties
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
};
// Example: Pick<{ name: string; age: number; email: string }, 'name' | 'email'>
// → { name: string; email: string }
// Omit specific properties
// Exclude removes K from the union of all keys, then Pick selects the rest
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
// Example: Omit<{ name: string; age: number; email: string }, 'age'>
// → { name: string; email: string }
How Mapped Types Work:
-
keyof T: Gets a union of all property names in T (e.g.,"name" | "age" | "email"). -
[P in Keys]: Iterates over each key in the union, like a for loop. -
Property Modifiers:
?for optional,readonlyfor immutable,-?to remove optional,-readonlyto remove readonly. -
Property Type:
T[P]accesses the type of property P in T.
Custom Mapped Types
interface Payment {
id: string;
amount: number;
currency: string;
status: 'pending' | 'completed' | 'failed';
metadata?: Record<string, unknown>;
}
// Nullable version of all properties
type Nullable<T> = {
[P in keyof T]: T[P] | null;
};
type NullablePayment = Nullable<Payment>;
// { id: string | null; amount: number | null; ... }
// Extract only string properties
type StringProperties<T> = {
[K in keyof T as T[K] extends string ? K : never]: T[K];
};
type PaymentStrings = StringProperties<Payment>;
// { id: string; currency: string; status: 'pending' | 'completed' | 'failed' }
// Prefix all property names
type Prefixed<T, P extends string> = {
[K in keyof T as `${P}${Capitalize<string & K>}`]: T[K];
};
type PrefixedPayment = Prefixed<Payment, 'payment'>;
// { paymentId: string; paymentAmount: number; ... }
// Make specific properties optional
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
type PaymentUpdate = PartialBy<Payment, 'status' | 'metadata'>;
// id, amount, currency required; status, metadata optional
Conditional Types
Basic Conditional Types
// Extract return type from function
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
// Extract promise value type
type Awaited<T> = T extends Promise<infer U> ? U : T;
// Filter out null/undefined
type NonNullable<T> = T extends null | undefined ? never : T;
Custom Conditional Types
// Extract success/failure from discriminated union
type PaymentResult =
| { type: 'success'; transactionId: string; amount: number }
| { type: 'failure'; error: string; code: string };
type ExtractSuccess<T> = T extends { type: 'success' }
? T
: never;
type SuccessResult = ExtractSuccess<PaymentResult>;
// { type: 'success'; transactionId: string; amount: number }
// Flatten nested arrays
type Flatten<T> = T extends Array<infer U>
? U extends Array<infer V>
? Flatten<V>
: U
: T;
type NestedArray = number[][][];
type Flattened = Flatten<NestedArray>; // number
// Extract function parameters
type Parameters<T> = T extends (...args: infer P) => any ? P : never;
type ProcessPayment = (id: string, amount: number) => Promise<void>;
type Params = Parameters<ProcessPayment>; // [string, number]
Template Literal Types
Building Type-Safe Routes
type Method = 'GET' | 'POST' | 'PUT' | 'DELETE';
type Resource = 'payments' | 'accounts' | 'transactions';
// API routes
type Route = `/${Resource}`;
type RouteWithId = `/${Resource}/${string}`;
const route1: Route = '/payments'; //
const route2: Route = '/users'; // Error
// Event names
type Entity = 'payment' | 'account' | 'transaction';
type Action = 'created' | 'updated' | 'deleted' | 'validated';
type EventName = `${Entity}.${Action}`;
const event: EventName = 'payment.created'; //
const invalid: EventName = 'payment.approved'; // Error
// HTTP headers
type ContentType =
| 'application/json'
| 'application/xml'
| 'text/plain';
type HttpHeader<T extends string> = `${T}: ${ContentType}`;
const header: HttpHeader<'Content-Type'> = 'Content-Type: application/json';
Extract Parts from Template Literals
// Extract currency code from formatted string
type CurrencyString = `${number} ${string}`;
type ExtractCurrency<T extends CurrencyString> =
T extends `${number} ${infer C}` ? C : never;
type USD = ExtractCurrency<'100 USD'>; // 'USD'
type EUR = ExtractCurrency<'250 EUR'>; // 'EUR'
// Parse route parameters
type ParseRouteParams<T extends string> =
T extends `${infer _Start}/:${infer Param}/${infer Rest}`
? Param | ParseRouteParams<`/${Rest}`>
: T extends `${infer _Start}/:${infer Param}`
? Param
: never;
type Params = ParseRouteParams<'/api/payments/:id/transactions/:txnId'>;
// 'id' | 'txnId'
Generic Constraints
Extending Interfaces
interface Identifiable {
id: string;
}
// Generic type that requires ID
function findById<T extends Identifiable>(
items: T[],
id: string
): T | undefined {
return items.find(item => item.id === id);
}
interface Payment extends Identifiable {
amount: number;
currency: string;
}
const payments: Payment[] = [
{ id: 'PAY-1', amount: 100, currency: 'USD' }
];
const payment = findById(payments, 'PAY-1'); // Payment | undefined
Constraining by Keys
// Type-safe property accessor
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const payment = { id: 'PAY-1', amount: 100 };
const amount = getProperty(payment, 'amount'); // number
const invalid = getProperty(payment, 'invalid'); // Error
// Multiple key constraints
function pick<T, K extends keyof T>(obj: T, ...keys: K[]): Pick<T, K> {
const result = {} as Pick<T, K>;
keys.forEach(key => {
result[key] = obj[key];
});
return result;
}
const summary = pick(payment, 'id', 'amount');
// { id: string; amount: number }
Discriminated Unions
State Machine Types
Discriminated unions (also called tagged unions) are one of TypeScript's most powerful features. They model mutually exclusive states with different data for each state, preventing impossible states and enabling exhaustive checking.
// Each variant has a literal 'status' property that identifies it
// This is the discriminant - TypeScript uses it to narrow types
type PaymentState =
| { status: 'idle' } // Waiting to start - no additional data needed
| { status: 'loading'; startedAt: Date } // In progress - track when it started
| { status: 'success'; data: Payment; completedAt: Date } // Succeeded - have payment data
| { status: 'error'; error: Error; failedAt: Date }; // Failed - have error details
// TypeScript narrows the type based on the discriminant
function renderPaymentState(state: PaymentState): string {
// state could be any of the four variants here
switch (state.status) {
case 'idle':
// TypeScript knows: state = { status: 'idle' }
// Can't access startedAt, data, error - they don't exist on this variant
return 'Ready to process payment';
case 'loading':
// TypeScript knows: state = { status: 'loading'; startedAt: Date }
// Can safely access startedAt
return `Processing since ${state.startedAt.toISOString()}`;
case 'success':
// TypeScript knows: state = { status: 'success'; data: Payment; completedAt: Date }
// Can safely access data and completedAt
return `Completed: ${state.data.id}`;
case 'error':
// TypeScript knows: state = { status: 'error'; error: Error; failedAt: Date }
// Can safely access error and failedAt
return `Failed: ${state.error.message}`;
default:
// Exhaustiveness check
// If all cases are handled, state has type never here
// If you add a new variant and forget to handle it, this line errors
const _exhaustive: never = state;
return _exhaustive;
}
}
Why Discriminated Unions Are Powerful:
-
Impossible States Prevented: You can't have
{ status: 'loading'; data: Payment }- loading state has no data. The type system prevents nonsensical combinations. -
Type Narrowing: After checking the discriminant, TypeScript knows exactly which variant you have, enabling safe access to variant-specific properties.
-
Exhaustiveness Checking: The
nevercheck at the end ensures you handle all cases. Add a new variant? TypeScript errors until you handle it. -
Self-Documenting: The type shows all possible states and their associated data at a glance.
Real-World Example:
// BAD: Boolean flags create impossible states
interface PaymentStateBad {
isLoading: boolean;
isSuccess: boolean;
isError: boolean;
data?: Payment;
error?: Error;
}
// What does { isLoading: true, isSuccess: true, data: undefined, error: Error } mean?
// This is an impossible state, but TypeScript allows it!
// GOOD: Discriminated union prevents impossible states
type PaymentStateGood =
| { status: 'loading' }
| { status: 'success'; data: Payment }
| { status: 'error'; error: Error };
// Can't represent impossible states - you're either loading, succeeded, or failed
Result Types
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
// Type guard
function isOk<T, E>(result: Result<T, E>): result is { ok: true; value: T } {
return result.ok === true;
}
// Usage
async function processPayment(amount: number): Promise<Result<string, string>> {
if (amount <= 0) {
return { ok: false, error: 'Amount must be positive' };
}
return { ok: true, value: 'TXN-123' };
}
const result = await processPayment(100);
if (isOk(result)) {
console.log('Transaction ID:', result.value);
} else {
console.error('Error:', result.error);
}
Recursive Types
Nested Structures
// Tree structure
interface TreeNode<T> {
value: T;
children?: TreeNode<T>[];
}
// Deep partial
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object
? DeepPartial<T[P]>
: T[P];
};
interface PaymentDetails {
amount: number;
metadata: {
reference: string;
tags: string[];
};
}
type PartialDetails = DeepPartial<PaymentDetails>;
// All properties at all levels are optional
// Deep readonly
type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends object
? DeepReadonly<T[P]>
: T[P];
};
Path Types
// Type-safe object paths
type PathImpl<T, Key extends keyof T> =
Key extends string
? T[Key] extends Record<string, any>
? `${Key}.${PathImpl<T[Key], Exclude<keyof T[Key], keyof any[]>> & string}` | `${Key}`
: `${Key}`
: never;
type Path<T> = PathImpl<T, keyof T> | keyof T;
interface NestedPayment {
id: string;
details: {
amount: number;
currency: string;
metadata: {
reference: string;
};
};
}
type PaymentPath = Path<NestedPayment>;
// 'id' | 'details' | 'details.amount' | 'details.currency' |
// 'details.metadata' | 'details.metadata.reference'
// Get value at path
function getValueAtPath<T, P extends Path<T>>(
obj: T,
path: P
): any {
return path.split('.').reduce((acc: any, key) => acc[key], obj);
}
Branded Types
Opaque Types for Domain Safety
Branded types (also called nominal types or opaque types) prevent mixing up similar primitive types by adding a compile-time "brand" that makes types incompatible even when their runtime representation is identical. This is crucial for distinguishing between semantically different values that have the same underlying type.
// Prevent mixing up similar primitive types
// The brand ({ readonly __brand: 'TypeName' }) exists only at compile time
// At runtime, these are just strings - the brand is phantom type information
type AccountId = string & { readonly __brand: 'AccountId' };
type PaymentId = string & { readonly __brand: 'PaymentId' };
type TransactionId = string & { readonly __brand: 'TransactionId' };
// Constructor functions validate and cast to branded type
// This is the only safe way to create branded types
function createAccountId(id: string): AccountId {
// Validation logic enforces business rules
if (!id.startsWith('ACC-')) {
throw new Error('Invalid account ID format');
}
// Type assertion is safe because we validated
return id as AccountId;
}
function createPaymentId(id: string): PaymentId {
if (!id.startsWith('PAY-')) {
throw new Error('Invalid payment ID format');
}
return id as PaymentId;
}
// Usage demonstrates type safety
function processPayment(
accountId: AccountId,
paymentId: PaymentId
): void {
// TypeScript ensures you can't accidentally swap parameters
// Even though both are strings at runtime, they're incompatible at compile time
}
// Create branded values using constructors
const accountId = createAccountId('ACC-123');
const paymentId = createPaymentId('PAY-456');
processPayment(accountId, paymentId); // Correct order
processPayment(paymentId, accountId); // Error: Types incompatible
processPayment('ACC-123', 'PAY-456'); // Error: Must use constructor
Why Branded Types Matter:
Without branded types, this compiles but is wrong:
// WITHOUT BRANDING: All IDs are just strings
function fetchPayment(accountId: string, paymentId: string): Payment {
// Easy to accidentally swap - TypeScript can't help
return api.get(`/accounts/${paymentId}/payments/${accountId}`); // BUG!
}
const accountId = 'ACC-123';
const paymentId = 'PAY-456';
// This compiles but swaps the IDs - creates runtime bug
fetchPayment(paymentId, accountId); // No compile error!
With branded types:
// WITH BRANDING: IDs are incompatible types
function fetchPayment(accountId: AccountId, paymentId: PaymentId): Payment {
// If you swap these, it's a compile error
return api.get(`/accounts/${accountId}/payments/${paymentId}`);
}
const accountId = createAccountId('ACC-123');
const paymentId = createPaymentId('PAY-456');
// This won't compile - parameters are incompatible
fetchPayment(paymentId, accountId); // Compile error caught!
When to Use Branded Types:
-
IDs and Identifiers: Distinguish account IDs from payment IDs from user IDs.
-
Units and Measurements: Prevent mixing meters with feet, seconds with milliseconds.
-
Validated Strings: Mark strings that have passed validation (email addresses, URLs, phone numbers).
-
Domain Values: Distinguish semantically different values with the same runtime type.
Benefits:
- Compile-Time Safety: Catch parameter swaps and type confusion at compile time.
- Zero Runtime Cost: Brands are phantom types - no runtime overhead.
- Self-Documenting: Function signatures clearly show what kind of value is expected.
- Validation Enforcement: Constructors ensure values meet business rules before they're branded.
Builder Pattern with Types
Type-Safe Fluent Builders
interface Payment {
id: string;
amount: number;
currency: string;
metadata?: Record<string, string>;
tags?: string[];
}
// Track which required fields have been set
type Builder<T, Required extends keyof T = never> = {
set<K extends keyof T>(
key: K,
value: T[K]
): Builder<T, Required | K>;
build: Required extends keyof T
? () => T
: 'Missing required fields';
};
class PaymentBuilder implements Builder<Payment> {
private payment: Partial<Payment> = {};
set<K extends keyof Payment>(key: K, value: Payment[K]): this {
this.payment[key] = value;
return this;
}
build(): Payment {
// Runtime validation
if (!this.payment.id || !this.payment.amount || !this.payment.currency) {
throw new Error('Missing required fields');
}
return this.payment as Payment;
}
}
// Usage
const payment = new PaymentBuilder()
.set('id', 'PAY-123')
.set('amount', 100)
.set('currency', 'USD')
.set('metadata', { reference: 'REF-456' })
.build(); //
// Compile error if missing required fields
// const incomplete = new PaymentBuilder()
// .set('id', 'PAY-123')
// .build(); // Error
Type-Level Programming with Infer
The infer keyword allows TypeScript to extract and capture types during conditional type evaluation. It's the foundation for advanced type manipulation, enabling you to extract function parameters, return types, promise values, array elements, and more. Think of infer as a way to "pattern match" on types and bind parts of that pattern to type variables.
Extracting Function Signatures
// Extract return type from any function
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
// "If T is a function with any parameters returning R, give me R; otherwise never"
type ProcessPayment = (id: string, amount: number) => Promise<PaymentResult>;
type Result = ReturnType<ProcessPayment>; // Promise<PaymentResult>
// Extract parameter types as tuple
type Parameters<T> = T extends (...args: infer P) => any ? P : never;
type Params = Parameters<ProcessPayment>; // [string, number]
// Extract first parameter
type FirstParameter<T> = T extends (first: infer F, ...rest: any[]) => any ? F : never;
type FirstParam = FirstParameter<ProcessPayment>; // string
// Extract constructor parameter types
type ConstructorParameters<T> = T extends new (...args: infer P) => any ? P : never;
class PaymentService {
constructor(public apiKey: string, public timeout: number) {}
}
type ServiceParams = ConstructorParameters<typeof PaymentService>; // [string, number]
Unwrapping Nested Types
infer is particularly powerful for unwrapping nested generic types like promises, arrays, and observables. The conditional type checks if the type matches the pattern, and if so, infer captures the inner type.
// Unwrap Promise to get inner type
type Awaited<T> = T extends Promise<infer U> ? U : T;
type AsyncPayment = Promise<Payment>;
type UnwrappedPayment = Awaited<AsyncPayment>; // Payment
// Unwrap nested Promises recursively
type DeepAwaited<T> = T extends Promise<infer U>
? DeepAwaited<U> // Keep unwrapping if inner type is also a Promise
: T;
type DeepAsyncPayment = Promise<Promise<Promise<Payment>>>;
type DeepUnwrapped = DeepAwaited<DeepAsyncPayment>; // Payment
// Extract array element type
type ArrayElement<T> = T extends (infer E)[] ? E : never;
type PaymentArray = Payment[];
type Element = ArrayElement<PaymentArray>; // Payment
// Extract object value types
type ValueOf<T> = T[keyof T];
interface PaymentStatus {
pending: 'PENDING';
completed: 'COMPLETED';
failed: 'FAILED';
}
type StatusValue = ValueOf<PaymentStatus>; // 'PENDING' | 'COMPLETED' | 'FAILED'
Distributive Conditional Types
Conditional types distribute over union types when the checked type is a naked type parameter (no array, tuple, or other wrapper). This means T extends U ? X : Y with T = A | B becomes (A extends U ? X : Y) | (B extends U ? X : Y). This behavior is crucial for filtering and transforming unions.
// Filter union to extract only specific types
type ExtractStrings<T> = T extends string ? T : never;
type Mixed = string | number | boolean | string[];
type JustStrings = ExtractStrings<Mixed>; // string (not string[])
// Distributes: ExtractStrings<string> | ExtractStrings<number> | ...
// = string | never | never | never = string
// Extract function types from union
type ExtractFunctions<T> = T extends (...args: any[]) => any ? T : never;
type MixedTypes = string | ((x: number) => void) | number | (() => string);
type JustFunctions = ExtractFunctions<MixedTypes>;
// ((x: number) => void) | (() => string)
// Filter out null/undefined
type NonNullable<T> = T extends null | undefined ? never : T;
type MaybePayment = Payment | null | undefined;
type DefinitelyPayment = NonNullable<MaybePayment>; // Payment
// Prevent distribution by wrapping in tuple
type NoDistribute<T> = [T] extends [string] ? T : never;
type DistributeTest = NoDistribute<string | number>; // never
// [string | number] extends [string] ? (string | number) : never = never
// Without [], it would distribute and return string
Advanced Pattern Matching
// Extract parts of template literal types
type ExtractRouteParams<T extends string> =
T extends `${infer _Start}/:${infer Param}/${infer Rest}`
? Param | ExtractRouteParams<`/${Rest}`> // Recursive: extract Param and continue
: T extends `${infer _Start}/:${infer Param}`
? Param // Base case: last parameter
: never; // No parameters found
type Route = '/api/accounts/:accountId/payments/:paymentId';
type RouteParams = ExtractRouteParams<Route>; // 'accountId' | 'paymentId'
// Transform string literal unions
type Uppercase<S extends string> = S extends `${infer F}${infer R}`
? `${Uppercase<F>}${Uppercase<R>}`
: S;
// Extract event payload from event type
type ExtractPayload<T> = T extends { type: string; payload: infer P }
? P
: never;
type PaymentEvent = { type: 'payment.created'; payload: Payment };
type Payload = ExtractPayload<PaymentEvent>; // Payment
Type-Level Functions
You can compose conditional types and infer to create reusable "functions" at the type level. These take types as input and produce types as output, similar to runtime functions but evaluated by the compiler.
// Deeply readonly - recursively make all properties readonly
type DeepReadonly<T> = T extends Primitive
? T // Primitives can't have properties, return as-is
: {
readonly [K in keyof T]: DeepReadonly<T[K]>; // Recurse into properties
};
type Primitive = string | number | boolean | null | undefined | symbol | bigint;
interface PaymentData {
id: string;
details: {
amount: number;
metadata: {
tags: string[];
};
};
}
type ImmutablePayment = DeepReadonly<PaymentData>;
// All properties and nested properties are readonly
// Flatten nested unions
type FlattenUnion<T> = T extends (infer U)[]
? FlattenUnion<U> // If array, recurse on element type
: T extends readonly (infer U)[]
? FlattenUnion<U> // Handle readonly arrays
: T; // Base case
type Nested = (string | number)[][];
type Flattened = FlattenUnion<Nested>; // string | number
// Get nested property type by path
type GetByPath<T, Path extends string> =
Path extends `${infer K}.${infer Rest}`
? K extends keyof T
? GetByPath<T[K], Rest> // Recurse: get T[K], then continue with Rest
: never
: Path extends keyof T
? T[Path] // Base case: return property type
: never;
type AmountType = GetByPath<PaymentData, 'details.amount'>; // number
type TagsType = GetByPath<PaymentData, 'details.metadata.tags'>; // string[]
Variance and Covariance Deep Dive
Variance describes how subtyping relationships between types relate to subtyping relationships between their compositions (generics). Understanding variance is essential for type safety with functions, promises, and data structures. TypeScript enforces variance rules to prevent type unsoundness - situations where types promise guarantees they can't keep.
Covariance: Return Types and Producers
A type is covariant in a position if you can substitute a more specific (derived) type for a less specific (base) type. Return types in TypeScript are covariant - if DetailedPayment extends Payment, then () => DetailedPayment is assignable to () => Payment. This makes sense: if you expect a function returning a Payment, and I give you one returning a DetailedPayment (which has everything a Payment has), that's safe.
interface Payment {
id: string;
amount: number;
currency: string;
}
interface DetailedPayment extends Payment {
description: string;
metadata: Record<string, unknown>;
}
// Producer: function that returns values
type Producer<T> = () => T;
let producePayment: Producer<Payment> = () => ({
id: 'PAY-1',
amount: 100,
currency: 'USD'
});
let produceDetailed: Producer<DetailedPayment> = () => ({
id: 'PAY-1',
amount: 100,
currency: 'USD',
description: 'Test',
metadata: {}
});
// SAFE: Covariance - can assign more specific return type
producePayment = produceDetailed;
// If I expect Payment and you give me DetailedPayment, that's fine -
// DetailedPayment has all properties of Payment (and more)
// UNSAFE: Reverse assignment would be unsound
// produceDetailed = producePayment; // Error!
// If I expect DetailedPayment.description and you give me Payment (no description), runtime error
// Arrays are covariant in TypeScript (unsound, but practical)
let payments: Payment[] = [];
let detailedPayments: DetailedPayment[] = [];
payments = detailedPayments; // Allowed (reading is safe)
payments.push({ id: 'PAY-2', amount: 50, currency: 'USD' }); // RUNTIME ERROR!
// We just pushed a Payment into DetailedPayment[] - violates type safety
// TypeScript allows this for convenience, but it's technically unsound
Contravariance: Parameter Types and Consumers
A type is contravariant in a position if you can substitute a less specific (base) type for a more specific (derived) type - the opposite of covariance. Function parameters in TypeScript are contravariant (with strictFunctionTypes enabled). If DetailedPayment extends Payment, then (p: Payment) => void is assignable to (p: DetailedPayment) => void. This seems backward but is safe: if you expect a function that handles DetailedPayment, and I give you one that handles any Payment, it will safely handle DetailedPayment (which is a Payment).
// Consumer: function that accepts values
type Consumer<T> = (value: T) => void;
let consumePayment: Consumer<Payment> = (p) => {
// Only access properties that exist on all Payments
console.log(p.id, p.amount);
};
let consumeDetailed: Consumer<DetailedPayment> = (p) => {
// Can access DetailedPayment-specific properties
console.log(p.description, p.metadata);
};
// SAFE: Contravariance - can assign less specific parameter type
consumeDetailed = consumePayment;
// If I expect a function that handles DetailedPayment, and you give me one
// that handles any Payment, that's safe - it won't access description (which might not exist)
// UNSAFE: Reverse assignment would be unsound
// consumePayment = consumeDetailed; // Error!
// If I expect to pass any Payment, and your function requires description,
// I might pass a Payment without description - runtime error
// Function parameter contravariance with strictFunctionTypes
type Handler<T> = (event: T) => void;
let handleBaseEvent: Handler<{ type: string }> = (e) => {
console.log(e.type);
};
let handlePaymentEvent: Handler<{ type: string; paymentId: string }> = (e) => {
console.log(e.type, e.paymentId);
};
handlePaymentEvent = handleBaseEvent; // Contravariance in parameters
Invariance: Read-Write Types
A type is invariant when it must be exactly the same type - neither more nor less specific. Mutable data structures (arrays in strict mode, objects with read-write properties) are invariant because they are both read (covariant) and written (contravariant). Allowing covariance would make writes unsafe; allowing contravariance would make reads unsafe. The only safe option is requiring exact type match.
// Invariance in mutable structures
interface Box<T> {
value: T;
setValue(v: T): void;
getValue(): T;
}
declare let paymentBox: Box<Payment>;
declare let detailedBox: Box<DetailedPayment>;
// Both assignments rejected - Box is invariant
// paymentBox = detailedBox; // Error: Covariance unsafe (setValue)
// detailedBox = paymentBox; // Error: Contravariance unsafe (getValue)
// Why covariance is unsafe:
// If we allowed paymentBox = detailedBox, then:
paymentBox.setValue({ id: 'PAY-1', amount: 100, currency: 'USD' }); // No description!
// But detailedBox expects DetailedPayment with description - runtime error
// Why contravariance is unsafe:
// If we allowed detailedBox = paymentBox, then:
const detailed = detailedBox.getValue(); // Expects DetailedPayment
console.log(detailed.description); // But might be plain Payment - runtime error
Bivariance and Method Parameters (Legacy Behavior)
Before TypeScript 2.6 and strictFunctionTypes, method parameters were bivariant (both covariant and contravariant). This is unsound but was allowed for compatibility with common JavaScript patterns. With strictFunctionTypes: true, function parameters are properly contravariant. However, method parameters in object types remain bivariant for backward compatibility. Prefer function properties over methods for stricter type checking.
// Method syntax: parameters are bivariant (unsafe, but allowed for compatibility)
interface PaymentProcessor {
process(payment: DetailedPayment): void; // Method syntax
}
let processor: PaymentProcessor = {
process(p: Payment) { // Allowed even though Payment is less specific
console.log(p.id); // Doesn't access description
}
};
// Function property syntax: parameters are properly contravariant (safe)
interface StrictPaymentProcessor {
process: (payment: DetailedPayment) => void; // Function property
}
let strictProcessor: StrictPaymentProcessor = {
process: (p: Payment) => { // Error with strictFunctionTypes
console.log(p.id);
}
};
// GOOD: Prefer function properties for strict type checking
interface SafeProcessor {
readonly process: (payment: DetailedPayment) => void;
}
Variance in Practice
// Covariant: Promises, Observables, return types
type AsyncPaymentResult = Promise<DetailedPayment>;
let basicPromise: Promise<Payment> = Promise.resolve({
id: 'PAY-1',
amount: 100,
currency: 'USD'
});
let detailedPromise: Promise<DetailedPayment> = Promise.resolve({
id: 'PAY-1',
amount: 100,
currency: 'USD',
description: 'Test',
metadata: {}
});
basicPromise = detailedPromise; // Covariance (promises are read-only)
// Contravariant: Event handlers, callbacks
type EventHandler<T> = (event: T) => void;
let handlePayment: EventHandler<Payment> = (p) => console.log(p.id);
let handleDetailed: EventHandler<DetailedPayment> = (p) => console.log(p.description);
handleDetailed = handlePayment; // Contravariance (callbacks consume values)
// Invariant: Mutable state stores
interface Store<T> {
get(): T;
set(value: T): void;
}
declare let paymentStore: Store<Payment>;
declare let detailedStore: Store<DetailedPayment>;
// Both rejected - Store is invariant (has both get and set)
// paymentStore = detailedStore; // Error
// detailedStore = paymentStore; // Error
Advanced Generic Patterns
Higher-Kinded Types Emulation
TypeScript doesn't have native higher-kinded types (types that abstract over type constructors like Array<T>, Promise<T>). However, you can emulate them using interface-based encoding. This pattern is useful for abstracting over generic types in libraries, allowing you to write generic code that works with Promise, Array, Option, etc.
// Encode type constructor as interface with apply method
interface TypeConstructor<F> {
// F is the type constructor (e.g., Array, Promise)
// _A is phantom type (exists only at compile time)
_A?: never;
_F?: F;
}
// Register type constructors
interface ArrayConstructor<A> extends TypeConstructor<'Array'> {
_A?: A;
}
interface PromiseConstructor<A> extends TypeConstructor<'Promise'> {
_A?: A;
}
// Apply type constructor to type argument
type Apply<F extends TypeConstructor<any>, A> = F extends TypeConstructor<
infer K
>
? K extends 'Array'
? Array<A>
: K extends 'Promise'
? Promise<A>
: never
: never;
// Functor interface: types that can be mapped over
interface Functor<F extends TypeConstructor<any>> {
map<A, B>(fa: Apply<F, A>, f: (a: A) => B): Apply<F, B>;
}
// Implement Functor for Array
const arrayFunctor: Functor<ArrayConstructor<any>> = {
map: (arr, f) => arr.map(f)
};
// Implement Functor for Promise
const promiseFunctor: Functor<PromiseConstructor<any>> = {
map: (promise, f) => promise.then(f)
};
// Generic function that works with any Functor
function doubleValues<F extends TypeConstructor<any>>(
functor: Functor<F>,
fa: Apply<F, number>
): Apply<F, number> {
return functor.map(fa, x => x * 2);
}
// Works with arrays
const doubledArray = doubleValues(arrayFunctor, [1, 2, 3]); // [2, 4, 6]
// Works with promises
const doubledPromise = doubleValues(promiseFunctor, Promise.resolve(5)); // Promise<10>
Type-Level Arithmetic
TypeScript can perform arithmetic at the type level using recursive conditional types and tuple length. This is useful for enforcing array lengths, building type-safe APIs with numbered parameters, or encoding constraints in types.
// Build tuple of length N
type BuildTuple<N extends number, T extends any[] = []> =
T['length'] extends N
? T // Base case: tuple has N elements
: BuildTuple<N, [...T, any]>; // Recursive: add element and continue
type ThreeTuple = BuildTuple<3>; // [any, any, any]
// Add two numbers at type level
type Add<A extends number, B extends number> =
[...BuildTuple<A>, ...BuildTuple<B>]['length'];
type Sum = Add<5, 3>; // 8
// Subtract (limited to non-negative results)
type Subtract<A extends number, B extends number> =
BuildTuple<A> extends [...BuildTuple<B>, ...infer Rest]
? Rest['length']
: never;
type Difference = Subtract<7, 3>; // 4
// Fixed-length array type
type FixedArray<T, N extends number> = N extends N
? BuildTuple<N> extends infer Tuple
? { [K in keyof Tuple]: T }
: never
: never;
type Coordinate3D = FixedArray<number, 3>; // [number, number, number]
const point: Coordinate3D = [1, 2, 3]; //
const invalid: Coordinate3D = [1, 2]; // Error: length 2, expected 3
Type-Level State Machines
You can encode finite state machines in the type system, making invalid state transitions impossible at compile time. This pattern is powerful for modeling workflows, protocols, and multi-step processes.
// Payment workflow states
type PaymentState =
| { status: 'draft'; amount: number; currency: string }
| { status: 'validated'; amount: number; currency: string; validatedAt: Date }
| { status: 'authorized'; amount: number; currency: string; validatedAt: Date; authCode: string }
| { status: 'captured'; amount: number; currency: string; validatedAt: Date; capturedAt: Date; transactionId: string }
| { status: 'failed'; amount: number; currency: string; error: string };
// Transition function type ensures only valid transitions
type Transition<From extends PaymentState['status'], To extends PaymentState['status']> =
(state: Extract<PaymentState, { status: From }>) => Extract<PaymentState, { status: To }>;
// Valid transitions
const validate: Transition<'draft', 'validated'> = (draft) => ({
...draft,
status: 'validated',
validatedAt: new Date()
});
const authorize: Transition<'validated', 'authorized'> = (validated) => ({
...validated,
status: 'authorized',
authCode: 'AUTH-123'
});
const capture: Transition<'authorized', 'captured'> = (authorized) => ({
amount: authorized.amount,
currency: authorized.currency,
validatedAt: authorized.validatedAt,
status: 'captured',
capturedAt: new Date(),
transactionId: 'TXN-456'
});
// Invalid transitions prevented by types
// const invalidSkip: Transition<'draft', 'captured'> = ... // Error!
// const invalidReverse: Transition<'captured', 'draft'> = ... // Error!
// Type-safe workflow executor
class PaymentWorkflow {
constructor(private state: PaymentState) {}
validate(this: PaymentWorkflow & { state: Extract<PaymentState, { status: 'draft' }> }) {
this.state = validate(this.state);
return this as PaymentWorkflow & { state: Extract<PaymentState, { status: 'validated' }> };
}
authorize(this: PaymentWorkflow & { state: Extract<PaymentState, { status: 'validated' }> }) {
this.state = authorize(this.state);
return this as PaymentWorkflow & { state: Extract<PaymentState, { status: 'authorized' }> };
}
capture(this: PaymentWorkflow & { state: Extract<PaymentState, { status: 'authorized' }> }) {
this.state = capture(this.state);
return this as PaymentWorkflow & { state: Extract<PaymentState, { status: 'captured' }> };
}
getState(): PaymentState {
return this.state;
}
}
// Valid workflow
const workflow = new PaymentWorkflow({ status: 'draft', amount: 100, currency: 'USD' });
workflow
.validate()
.authorize()
.capture();
// Invalid workflow rejected at compile time
// workflow.authorize(); // Error: must validate first
// workflow.capture(); // Error: must validate and authorize first
Phantom Types for Type-Level Guarantees
Phantom types are type parameters that don't appear in the actual data structure but provide compile-time guarantees. They're useful for tracking provenance (where data came from), state (validated vs. raw), or permissions (read vs. write).
// Phantom type parameter tracks validation status
interface ValidatedData<T> {
_validated: true; // Phantom field - never actually exists at runtime
data: T;
}
interface UnvalidatedData<T> {
_validated: false;
data: T;
}
// Constructor creates unvalidated data
function createPaymentData(amount: number, currency: string): UnvalidatedData<{ amount: number; currency: string }> {
return { data: { amount, currency } } as UnvalidatedData<{ amount: number; currency: string }>;
}
// Validation function produces validated data
function validatePaymentData<T>(
unvalidated: UnvalidatedData<T>
): ValidatedData<T> {
// Validation logic here
if (typeof unvalidated.data !== 'object') {
throw new Error('Invalid data');
}
return { data: unvalidated.data } as ValidatedData<T>;
}
// API accepts only validated data
function processPayment<T>(validatedData: ValidatedData<T>): void {
// Type system guarantees this data has been validated
console.log('Processing validated payment:', validatedData.data);
}
// Usage
const rawData = createPaymentData(100, 'USD');
// processPayment(rawData); // Error: requires ValidatedData
const validated = validatePaymentData(rawData);
processPayment(validated); // Valid
Utility Type Composition
Combining Utility Types
interface Payment {
id: string;
amount: number;
currency: string;
status: 'pending' | 'completed' | 'failed';
createdAt: Date;
updatedAt: Date;
metadata?: Record<string, unknown>;
}
// Create request type: omit generated fields, make metadata required
type CreatePaymentRequest = Required<
Omit<Payment, 'id' | 'createdAt' | 'updatedAt'>
>;
// Update request: pick specific fields and make optional
type UpdatePaymentRequest = Partial<
Pick<Payment, 'status' | 'metadata'>
>;
// Response type: pick specific fields and add computed
type PaymentResponse = Pick<Payment, 'id' | 'amount' | 'status'> & {
formattedAmount: string;
};
// Audit log type: all fields readonly with timestamp
type PaymentAuditLog = Readonly<Payment> & {
auditTimestamp: Date;
changedBy: string;
};
Further Reading
Internal Documentation
- TypeScript General - TypeScript fundamentals
- TypeScript Testing - Testing type-safe code
- API Integration - Frontend - OpenAPI type generation
- React State Management - Type-safe state with Zustand
External Resources
Summary
Key Takeaways
- Mapped types transform existing types systematically (Partial, Readonly, Pick, custom transformations)
- Conditional types enable type-level branching and inference
- Template literal types create type-safe string patterns for routes, events, headers
- Generic constraints make generic types more specific and useful
- Discriminated unions model state machines and result types with exhaustiveness checking
- Recursive types handle nested structures with deep transformations
- Branded types prevent mixing up similar primitive types (IDs, tokens)
- Builder patterns enforce required fields at compile time
- Variance understanding helps with function type compatibility
- Compose utility types to create complex domain-specific types
Next Steps: Review React State Management for applying these patterns in state management with Zustand.