Expert TypeScript: Metaprogramming, Decorators, and Advanced Type Theory
This guide targets the upper echelons of TypeScript capability. We explore mechanisms used by top-tier libraries: conditional and mapped types, the `infer` keyword, decorators, and the nuances of covariance and contravariance to achieve absolute type safety.
Conditional Types
Conditional types allow you to create types that act like if/else statements at the type level, choosing between two possible types based on a condition using the extends keyword to test type relationships.
type IsString<T> = T extends string ? "yes" : "no"; type A = IsString<string>; // "yes" type B = IsString<number>; // "no" type C = IsString<"hello">; // "yes" // Practical example: Extract return type type ReturnOf<T> = T extends (...args: any[]) => infer R ? R : never; type Result = ReturnOf<() => string>; // string
Mapped Types
Mapped types transform existing types by iterating over their keys and applying transformations, enabling you to create new types based on the structure of existing ones using the in keyof syntax.
// Built-in examples recreated type MyReadonly<T> = { readonly [K in keyof T]: T[K] }; type MyPartial<T> = { [K in keyof T]?: T[K] }; type MyRequired<T> = { [K in keyof T]-?: T[K] }; // -? removes optional interface User { name: string; age: number; } type ReadonlyUser = MyReadonly<User>; // { readonly name: string; readonly age: number; } // Key remapping (TS 4.1+) type Getters<T> = { [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K] }; type UserGetters = Getters<User>; // { getName: () => string; getAge: () => number; }
Template Literal Types
Template literal types combine literal types with string interpolation syntax to create powerful string manipulation at the type level, enabling pattern matching and string transformation types.
type EventName<T extends string> = `on${Capitalize<T>}`; type ClickEvent = EventName<"click">; // "onClick" // String unions multiply type Vertical = "top" | "bottom"; type Horizontal = "left" | "right"; type Position = `${Vertical}-${Horizontal}`; // "top-left" | "top-right" | "bottom-left" | "bottom-right" // Built-in string manipulators type Upper = Uppercase<"hello">; // "HELLO" type Lower = Lowercase<"HELLO">; // "hello" type Cap = Capitalize<"hello">; // "Hello" type Uncap = Uncapitalize<"Hello">; // "hello" // Route parsing type ExtractParams<T extends string> = T extends `${string}:${infer Param}/${infer Rest}` ? Param | ExtractParams<Rest> : T extends `${string}:${infer Param}` ? Param : never; type Params = ExtractParams<"/users/:id/posts/:postId">; // "id" | "postId"
Infer Keyword
The infer keyword declares a type variable within a conditional type's extends clause, allowing you to extract and capture parts of types for use in the true branch of the condition.
// Extract array element type type ElementOf<T> = T extends (infer E)[] ? E : never; type Nums = ElementOf<number[]>; // number // Extract function parameter types type Parameters<T> = T extends (...args: infer P) => any ? P : never; type Params = Parameters<(a: string, b: number) => void>; // [string, number] // Extract Promise inner type type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T; type Inner = Awaited<Promise<Promise<string>>>; // string // Multiple infer positions type FirstAndLast<T> = T extends [infer F, ...any[], infer L] ? [F, L] : never; type FL = FirstAndLast<[1, 2, 3, 4]>; // [1, 4] // Infer in template literals type ExtractDomain<T> = T extends `https://${infer Domain}/${string}` ? Domain : never; type Domain = ExtractDomain<"https://google.com/search">; // "google.com"
Distributive Conditional Types
When a conditional type is applied to a union type, it automatically distributes over each member of the union, evaluating the condition for each union member separately and combining the results.
type ToArray<T> = T extends any ? T[] : never; // Distribution happens automatically with union type Result = ToArray<string | number>; // string[] | number[] (NOT (string | number)[]) // ┌─────────────────────────────────────────┐ // │ ToArray<string | number> │ // │ ↓ distributes to │ // │ ToArray<string> | ToArray<number> │ // │ ↓ evaluates to │ // │ string[] | number[] │ // └─────────────────────────────────────────┘ // Prevent distribution with tuple wrapper type ToArrayNonDist<T> = [T] extends [any] ? T[] : never; type Result2 = ToArrayNonDist<string | number>; // (string | number)[] // Practical: Exclude and Extract type MyExclude<T, U> = T extends U ? never : T; type MyExtract<T, U> = T extends U ? T : never; type Letters = "a" | "b" | "c"; type Filtered = MyExclude<Letters, "a">; // "b" | "c"
Recursive Types
Recursive types reference themselves in their definition, enabling you to model deeply nested or tree-like data structures and perform complex type-level computations on arbitrarily deep structures.
// JSON type definition type JSONValue = | string | number | boolean | null | JSONValue[] | { [key: string]: JSONValue }; // Deep readonly type DeepReadonly<T> = { readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K] }; // Deep partial type DeepPartial<T> = { [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K] }; // Flatten nested arrays type Flatten<T> = T extends (infer U)[] ? Flatten<U> : T; type Deep = number[][][]; type Flat = Flatten<Deep>; // number // Path types for nested objects type Path<T, K extends keyof T = keyof T> = K extends string ? T[K] extends object ? K | `${K}.${Path<T[K]>}` : K : never; interface Config { db: { host: string; port: number }; cache: { ttl: number }; } type ConfigPaths = Path<Config>; // "db" | "db.host" | "db.port" | "cache" | "cache.ttl"
Variadic Tuple Types
Variadic tuple types allow you to work with tuple types of varying lengths using spread syntax, enabling generic operations on tuples like concatenation, insertion, and extraction of elements.
// Tuple concatenation type Concat<T extends unknown[], U extends unknown[]> = [...T, ...U]; type Combined = Concat<[1, 2], [3, 4]>; // [1, 2, 3, 4] // Prepend/Append elements type Prepend<T, U extends unknown[]> = [T, ...U]; type Append<T extends unknown[], U> = [...T, U]; // Function with variadic parameters function concat<T extends unknown[], U extends unknown[]>( arr1: [...T], arr2: [...U] ): [...T, ...U] { return [...arr1, ...arr2]; } const result = concat([1, "a"], [true, 2]); // [number, string, boolean, number] // Partial application type Tail<T extends unknown[]> = T extends [any, ...infer Rest] ? Rest : never; type Head<T extends unknown[]> = T extends [infer First, ...any[]] ? First : never; type PartialCall<T extends unknown[], U extends unknown[]> = T extends [...U, ...infer Rest] ? Rest : never; type Remaining = PartialCall<[string, number, boolean], [string]>; // [number, boolean]
Function Overloading
Function overloading lets you define multiple function signatures for a single implementation, allowing the same function to accept different parameter types and return different types based on the input.
// Overload signatures (declarations) function parse(input: string): object; function parse(input: object): string; function parse(input: Buffer): object; // Implementation signature function parse(input: string | object | Buffer): object | string { if (typeof input === "string") return JSON.parse(input); if (Buffer.isBuffer(input)) return JSON.parse(input.toString()); return JSON.stringify(input); } // Usage - TypeScript picks correct overload const obj = parse('{"a":1}'); // object const str = parse({ a: 1 }); // string // Method overloading in class class Calculator { add(a: number, b: number): number; add(a: string, b: string): string; add(a: number | string, b: number | string): number | string { if (typeof a === "number" && typeof b === "number") return a + b; return String(a) + String(b); } } // Generic with overloads function createElement(tag: "a"): HTMLAnchorElement; function createElement(tag: "div"): HTMLDivElement; function createElement(tag: "span"): HTMLSpanElement; function createElement(tag: string): HTMLElement { return document.createElement(tag); }
Constructor Overloading
Constructor overloading allows a class to have multiple ways of instantiation with different parameter combinations, using overload signatures before the implementation constructor.
class Point { x: number; y: number; // Overload signatures constructor(); constructor(x: number, y: number); constructor(coords: { x: number; y: number }); constructor(coords: [number, number]); // Implementation constructor( xOrCoords?: number | { x: number; y: number } | [number, number], y?: number ) { if (xOrCoords === undefined) { this.x = 0; this.y = 0; } else if (typeof xOrCoords === "number") { this.x = xOrCoords; this.y = y!; } else if (Array.isArray(xOrCoords)) { [this.x, this.y] = xOrCoords; } else { this.x = xOrCoords.x; this.y = xOrCoords.y; } } } // All valid const p1 = new Point(); const p2 = new Point(10, 20); const p3 = new Point({ x: 10, y: 20 }); const p4 = new Point([10, 20]);
Mixins
Mixins are a pattern for composing classes from reusable components, allowing you to combine multiple class behaviors without traditional inheritance hierarchies using intersection types and factory functions.
// Mixin type helper type Constructor<T = {}> = new (...args: any[]) => T; // Timestamped mixin function Timestamped<TBase extends Constructor>(Base: TBase) { return class extends Base { createdAt = new Date(); updatedAt = new Date(); touch() { this.updatedAt = new Date(); } }; } // Serializable mixin function Serializable<TBase extends Constructor>(Base: TBase) { return class extends Base { serialize(): string { return JSON.stringify(this); } static deserialize(json: string) { return Object.assign(new this(), JSON.parse(json)); } }; } // Base class class User { constructor(public name: string) {} } // Compose mixins // ┌──────────────────────────────────────┐ // │ TimestampedUser │ // │ ┌────────────────────────────┐ │ // │ │ SerializableUser │ │ // │ │ ┌──────────────────┐ │ │ // │ │ │ User │ │ │ // │ │ └──────────────────┘ │ │ // │ └────────────────────────────┘ │ // └──────────────────────────────────────┘ const MixedUser = Timestamped(Serializable(User)); const user = new MixedUser("Alice"); user.touch(); console.log(user.serialize());
Decorators (Experimental and Stage 3)
Decorators are special functions that can modify or annotate classes, methods, properties, and parameters at design time, with TypeScript supporting both the legacy experimental syntax and the newer Stage 3 ECMAScript proposal.
// tsconfig.json: "experimentalDecorators": true // ═══════════════════════════════════════════ // LEGACY DECORATORS (Experimental) // ═══════════════════════════════════════════ // Class decorator function Component(selector: string) { return function <T extends new (...args: any[]) => {}>(constructor: T) { return class extends constructor { selector = selector; }; }; } // Method decorator function Log(target: any, key: string, descriptor: PropertyDescriptor) { const original = descriptor.value; descriptor.value = function (...args: any[]) { console.log(`Calling ${key} with`, args); return original.apply(this, args); }; } // Property decorator function Required(target: any, key: string) { let value: any; Object.defineProperty(target, key, { get: () => value, set: (v) => { if (!v) throw new Error(`${key} is required`); value = v; } }); } @Component("app-user") class UserComponent { @Required name!: string; @Log greet(msg: string) { return `${this.name}: ${msg}`; } } // ═══════════════════════════════════════════ // STAGE 3 DECORATORS (TS 5.0+) // ═══════════════════════════════════════════ function logged<T extends (...args: any[]) => any>( originalMethod: T, context: ClassMethodDecoratorContext ): T { return function (this: any, ...args: any[]) { console.log(`LOG: ${String(context.name)}`); return originalMethod.apply(this, args); } as T; }
Decorator Factories
Decorator factories are functions that return decorators, allowing you to pass configuration parameters to customize decorator behavior and create more flexible, reusable decorators.
// Simple decorator vs decorator factory // @sealed <- decorator // @configurable(false) <- decorator factory // Method decorator factory with options function Throttle(ms: number) { return function ( target: any, key: string, descriptor: PropertyDescriptor ) { const original = descriptor.value; let lastCall = 0; descriptor.value = function (...args: any[]) { const now = Date.now(); if (now - lastCall >= ms) { lastCall = now; return original.apply(this, args); } }; }; } // Validation decorator factory function Range(min: number, max: number) { return function (target: any, key: string) { let value: number; Object.defineProperty(target, key, { get: () => value, set: (v: number) => { if (v < min || v > max) { throw new RangeError(`${key} must be between ${min} and ${max}`); } value = v; } }); }; } class Player { @Range(0, 100) health!: number; @Throttle(1000) attack() { console.log("Attacking!"); } }
Metadata Reflection
Metadata reflection enables runtime inspection of type information using the Reflect API with the reflect-metadata library, essential for dependency injection frameworks and ORM implementations.
// npm install reflect-metadata // tsconfig.json: "emitDecoratorMetadata": true import "reflect-metadata"; // Custom metadata keys const INJECTABLE_KEY = Symbol("injectable"); const INJECT_KEY = Symbol("inject"); // Injectable decorator function Injectable() { return function (target: any) { Reflect.defineMetadata(INJECTABLE_KEY, true, target); }; } // Inject decorator function Inject(token: any) { return function (target: any, key: string | undefined, index: number) { const existing = Reflect.getMetadata(INJECT_KEY, target) || []; existing[index] = token; Reflect.defineMetadata(INJECT_KEY, existing, target); }; } // Simple DI container class Container { private instances = new Map(); resolve<T>(target: new (...args: any[]) => T): T { // Get constructor parameter types const paramTypes = Reflect.getMetadata("design:paramtypes", target) || []; const injections = Reflect.getMetadata(INJECT_KEY, target) || []; const deps = paramTypes.map((type: any, i: number) => this.resolve(injections[i] || type) ); return new target(...deps); } } @Injectable() class Logger { log(msg: string) { console.log(msg); } } @Injectable() class UserService { constructor(private logger: Logger) {} // Auto-injected via metadata }
Symbols
Symbols are unique, immutable primitive values that can be used as object property keys, providing a way to create truly private members and well-known behaviors that won't conflict with string keys.
// Creating symbols const id = Symbol("id"); const id2 = Symbol("id"); console.log(id === id2); // false - always unique // As property keys const USER_ID = Symbol("userId"); interface User { name: string; [USER_ID]: number; // Symbol-keyed property } const user: User = { name: "Alice", [USER_ID]: 123 }; console.log(user[USER_ID]); // 123 // Well-known symbols class Collection { private items: number[] = [1, 2, 3]; // Makes object iterable *[Symbol.iterator]() { yield* this.items; } // Customize instanceof behavior static [Symbol.hasInstance](obj: any) { return Array.isArray(obj?.items); } // String tag get [Symbol.toStringTag]() { return "Collection"; } } const col = new Collection(); console.log([...col]); // [1, 2, 3] console.log(Object.prototype.toString.call(col)); // [object Collection] // Global symbol registry const globalSym = Symbol.for("app.config"); const sameSym = Symbol.for("app.config"); console.log(globalSym === sameSym); // true
Iterators and Generators
Iterators provide a standard way to traverse collections, while generators are functions that can pause and resume execution, both working with the Symbol.iterator protocol for use in for...of loops and spread operations.
// Iterator interface interface Iterator<T> { next(): { value: T; done: boolean }; } // Custom iterable class class Range implements Iterable<number> { constructor(private start: number, private end: number) {} *[Symbol.iterator](): Generator<number> { for (let i = this.start; i <= this.end; i++) { yield i; } } } for (const n of new Range(1, 5)) console.log(n); // 1, 2, 3, 4, 5 // Generator function with types function* fibonacci(): Generator<number, void, undefined> { let [a, b] = [0, 1]; while (true) { yield a; [a, b] = [b, a + b]; } } // Async generator async function* fetchPages(urls: string[]): AsyncGenerator<Response> { for (const url of urls) { yield await fetch(url); } } // Generator with send value (bidirectional) function* accumulator(): Generator<number, number, number> { let total = 0; while (true) { const value = yield total; if (value === undefined) return total; total += value; } } const acc = accumulator(); acc.next(); // { value: 0, done: false } acc.next(10); // { value: 10, done: false } acc.next(5); // { value: 15, done: false }
Async/Await with Proper Typing
TypeScript provides comprehensive type support for asynchronous operations, inferring Promise return types and enabling proper error handling with typed async functions, Promise combinators, and async iterables.
// Async function return type inference async function fetchUser(id: number): Promise<User> { const response = await fetch(`/api/users/${id}`); if (!response.ok) throw new Error("Not found"); return response.json() as Promise<User>; } // Typed Promise combinators interface User { id: number; name: string; } interface Post { id: number; title: string; } async function loadDashboard(userId: number) { // Promise.all with tuple inference const [user, posts] = await Promise.all([ fetchUser(userId), fetchPosts(userId) ] as const); // user: User, posts: Post[] // Promise.allSettled const results = await Promise.allSettled([ fetchUser(1), fetchUser(999) // might fail ]); results.forEach(result => { if (result.status === "fulfilled") { console.log(result.value); // User } else { console.error(result.reason); // Error } }); } // Async iteration async function processStream(stream: ReadableStream<Uint8Array>) { const reader = stream.getReader(); try { while (true) { const { done, value } = await reader.read(); if (done) break; process(value); // value: Uint8Array } } finally { reader.releaseLock(); } } // Typed async error handling type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E }; async function safeAsync<T>(promise: Promise<T>): Promise<Result<T>> { try { return { ok: true, value: await promise }; } catch (error) { return { ok: false, error: error as Error }; } }
Module Augmentation
Module augmentation allows you to extend existing module declarations by adding new exports, types, or modifying existing interfaces from external libraries without modifying their source code.
// ═══════════════════════════════════════════ // Augmenting a third-party module (e.g., express) // ═══════════════════════════════════════════ // types/express.d.ts import "express"; declare module "express" { interface Request { user?: { id: string; email: string; roles: string[]; }; startTime?: number; } interface Response { success(data: any): void; error(message: string, code?: number): void; } } // Now in your code import express, { Request, Response } from "express"; app.use((req: Request, res: Response, next) => { req.startTime = Date.now(); req.user = { id: "123", email: "a@b.com", roles: ["admin"] }; next(); }); // ═══════════════════════════════════════════ // Augmenting your own modules // ═══════════════════════════════════════════ // utils.ts export interface Config { apiUrl: string; } // plugins/analytics.ts declare module "../utils" { interface Config { analyticsId?: string; } } // Result: Config now has both apiUrl and analyticsId
Global Augmentation
Global augmentation extends the global scope to add new global variables, modify built-in objects like Window or Array, and declare ambient module types available throughout your entire project.
// globals.d.ts export {}; // Makes this a module declare global { // Extend Window interface Window { __APP_CONFIG__: { version: string; environment: "dev" | "prod"; }; analytics: { track(event: string, data?: object): void; }; } // Extend built-in Array interface Array<T> { last(): T | undefined; first(): T | undefined; } // Add global variable var DEBUG: boolean; // Extend NodeJS namespace namespace NodeJS { interface ProcessEnv { NODE_ENV: "development" | "production" | "test"; DATABASE_URL: string; API_KEY: string; } } } // Implementation Array.prototype.last = function() { return this[this.length - 1]; }; // Usage anywhere in project window.__APP_CONFIG__.version; process.env.DATABASE_URL; // string, not string | undefined [1, 2, 3].last(); // number | undefined
Declaration Merging
Declaration merging automatically combines multiple declarations with the same name into a single definition, allowing interfaces, namespaces, classes, and enums to be extended across multiple declaration blocks.
// Interface merging (most common) interface User { name: string; } interface User { age: number; } // Result: interface User { name: string; age: number; } // Namespace + Class merging (companion object pattern) class Album { label: Album.Label; constructor(label: Album.Label) { this.label = label; } } namespace Album { export interface Label { name: string; address: string; } export function create(name: string): Album { return new Album({ name, address: "" }); } } const album = Album.create("Blue"); // static factory // Enum + Namespace merging enum Color { Red = 1, Green, Blue } namespace Color { export function random(): Color { return Math.floor(Math.random() * 3) + 1; } } Color.random(); // Color // ┌────────────────────────────────────────────────┐ // │ Declaration Merging Rules │ // ├──────────────────┬────────────────────────────┤ // │ Can Merge With │ Interface │ Namespace │ Class │ // ├──────────────────┼────────────┼───────────┼────────┤ // │ Interface │ ✓ │ ✓ │ ✓ │ // │ Namespace │ ✓ │ ✓ │ ✓ │ // │ Class │ ✗ │ ✓ │ ✗ │ // │ Enum │ ✗ │ ✓ │ ✗ │ // └──────────────────┴────────────┴───────────┴────────┘
Ambient Declarations
Ambient declarations describe the types of external code that exists elsewhere (like JavaScript libraries or global variables), using the declare keyword to tell TypeScript about things that exist at runtime without implementing them.
// Ambient variable (exists globally) declare var jQuery: (selector: string) => any; declare const API_URL: string; // Ambient function declare function greet(name: string): void; // Ambient class declare class ExternalWidget { constructor(config: object); render(): void; destroy(): void; } // Ambient module (for a JS library without types) // types/legacy-lib.d.ts declare module "legacy-lib" { export function doSomething(input: string): number; export class Helper { static format(data: any): string; } export default function init(): void; } // Wildcard module (for non-code imports) declare module "*.svg" { const content: string; export default content; } declare module "*.css" { const classes: { [key: string]: string }; export default classes; } // Ambient namespace (for UMD globals) declare namespace google.maps { class Map { constructor(element: HTMLElement, options?: MapOptions); } interface MapOptions { zoom?: number; center?: LatLng; } interface LatLng { lat: number; lng: number; } }
Covariance and Contravariance
Covariance and contravariance describe how type relationships are preserved through generic type transformations: covariant types preserve the direction of inheritance (output positions), while contravariant types reverse it (input positions).
// ┌─────────────────────────────────────────────────────────────┐ // │ Animal │ // │ ▲ │ // │ │ (Dog extends Animal) │ // │ Dog │ // │ │ // │ COVARIANCE (output/return position) - preserves direction │ // │ () => Dog is subtype of () => Animal ✓ │ // │ │ // │ CONTRAVARIANCE (input/param position) - reverses direction │ // │ (Animal) => void is subtype of (Dog) => void ✓ │ // └─────────────────────────────────────────────────────────────┘ class Animal { name = "animal"; } class Dog extends Animal { bark() {} } // Covariant: Producer<Dog> assignable to Producer<Animal> type Producer<T> = () => T; const produceDog: Producer<Dog> = () => new Dog(); const produceAnimal: Producer<Animal> = produceDog; // ✓ OK // Contravariant: Consumer<Animal> assignable to Consumer<Dog> type Consumer<T> = (item: T) => void; const consumeAnimal: Consumer<Animal> = (a) => console.log(a.name); const consumeDog: Consumer<Dog> = consumeAnimal; // ✓ OK // Invariant: both input and output (no substitution) type Transformer<T> = (item: T) => T; // Neither direction works for Transformer // TypeScript 4.7+: explicit variance annotations type ProducerExplicit<out T> = () => T; // Covariant type ConsumerExplicit<in T> = (item: T) => void; // Contravariant type TransformerExplicit<in out T> = (item: T) => T; // Invariant
Discriminated Unions
Discriminated unions (also called tagged unions) combine union types with a common literal type property (discriminant) that TypeScript uses to narrow the type in conditional branches, enabling type-safe handling of different cases.
// Each variant has a discriminant property ("type") interface Circle { type: "circle"; radius: number; } interface Rectangle { type: "rectangle"; width: number; height: number; } interface Triangle { type: "triangle"; base: number; height: number; } type Shape = Circle | Rectangle | Triangle; // ┌─────────────────────────────────────┐ // │ Shape Union │ // ├───────────┬───────────┬─────────────┤ // │ Circle │ Rectangle │ Triangle │ // ├───────────┼───────────┼─────────────┤ // │type:"circle"│type:"rectangle"│type:"triangle"│ // │ radius │width,height│ base,height │ // └───────────┴───────────┴─────────────┘ // ↑ discriminant narrows the union function area(shape: Shape): number { switch (shape.type) { case "circle": // shape is Circle here return Math.PI * shape.radius ** 2; case "rectangle": // shape is Rectangle here return shape.width * shape.height; case "triangle": // shape is Triangle here return 0.5 * shape.base * shape.height; } } // Result types pattern type Result<T, E = Error> = | { success: true; data: T } | { success: false; error: E }; function handle<T>(result: Result<T>) { if (result.success) { console.log(result.data); // T } else { console.error(result.error); // Error } }
Exhaustiveness Checking
Exhaustiveness checking ensures all possible cases of a union type are handled in switch statements or conditionals, with TypeScript producing compile-time errors if any variant is missed by assigning unhandled cases to never.
type Status = "pending" | "approved" | "rejected" | "cancelled"; // ✗ Missing case will error with exhaustiveness check function getStatusMessage(status: Status): string { switch (status) { case "pending": return "Waiting for review"; case "approved": return "Request approved"; case "rejected": return "Request rejected"; case "cancelled": return "Request cancelled"; default: // This ensures all cases are handled const _exhaustive: never = status; throw new Error(`Unhandled status: ${_exhaustive}`); } } // Helper function for exhaustiveness function assertNever(value: never, message?: string): never { throw new Error(message ?? `Unexpected value: ${value}`); } // With discriminated unions type Action = | { type: "INCREMENT"; amount: number } | { type: "DECREMENT"; amount: number } | { type: "RESET" }; function reducer(state: number, action: Action): number { switch (action.type) { case "INCREMENT": return state + action.amount; case "DECREMENT": return state - action.amount; case "RESET": return 0; default: return assertNever(action); // Compile error if case missed } } // If you add a new Action type, TypeScript forces you to handle it!
Branded Types / Nominal Typing
Branded types (also called nominal types or opaque types) create distinct types from primitive types using intersection with a unique symbol, preventing accidental mixing of semantically different values that share the same underlying type.
// The brand is a phantom type - exists only at compile time declare const brand: unique symbol; type Brand<T, B> = T & { [brand]: B }; // Create distinct types from primitives type UserId = Brand<string, "UserId">; type OrderId = Brand<string, "OrderId">; type Email = Brand<string, "Email">; type USD = Brand<number, "USD">; type EUR = Brand<number, "EUR">; // Constructor functions with validation function UserId(id: string): UserId { if (!id.startsWith("user_")) throw new Error("Invalid UserId"); return id as UserId; } function Email(email: string): Email { if (!email.includes("@")) throw new Error("Invalid email"); return email as Email; } function USD(amount: number): USD { if (amount < 0) throw new Error("Amount cannot be negative"); return amount as USD; } // Type safety in action function getUser(id: UserId) { /* ... */ } function getOrder(id: OrderId) { /* ... */ } function convertToEUR(amount: USD): EUR { return (amount * 0.85) as EUR; } const userId = UserId("user_123"); const orderId = "order_456" as OrderId; getUser(userId); // ✓ OK getUser(orderId); // ✗ Error: OrderId not assignable to UserId getUser("user_789"); // ✗ Error: string not assignable to UserId const dollars = USD(100); const euros = EUR(85); // dollars + euros; // ✗ Error: can't mix currency types
Type Predicates
Type predicates are special return types for functions that perform type narrowing, using the parameterName is Type syntax to tell TypeScript that a boolean return value guarantees a specific type for the parameter.
// Type predicate syntax: param is Type function isString(value: unknown): value is string { return typeof value === "string"; } // Usage - TypeScript narrows the type function process(value: unknown) { if (isString(value)) { console.log(value.toUpperCase()); // value is string } } // Complex type guards interface Fish { swim(): void; } interface Bird { fly(): void; } function isFish(pet: Fish | Bird): pet is Fish { return (pet as Fish).swim !== undefined; } // Array filtering with type predicates const mixed: (string | null | undefined)[] = ["a", null, "b", undefined]; const strings: string[] = mixed.filter((x): x is string => x != null); // Discriminated union guard type Result<T> = { ok: true; value: T } | { ok: false; error: Error }; function isSuccess<T>(result: Result<T>): result is { ok: true; value: T } { return result.ok === true; } // Class instance guard function isInstanceOf<T>( ctor: new (...args: any[]) => T ): (value: unknown) => value is T { return (value): value is T => value instanceof ctor; } const isDate = isInstanceOf(Date); const value: unknown = new Date(); if (isDate(value)) { value.getFullYear(); // value is Date }
Assertion Functions
Assertion functions use the asserts keyword to tell TypeScript that if the function returns (doesn't throw), a condition is guaranteed to be true, enabling type narrowing in the code following the assertion call.
// Assertion signature: asserts condition function assert(condition: unknown, msg?: string): asserts condition { if (!condition) throw new Error(msg ?? "Assertion failed"); } // Usage - narrows after assertion function process(value: string | null) { assert(value !== null, "Value is required"); // value is string after this point console.log(value.toUpperCase()); } // Type assertion function: asserts param is Type function assertIsString(value: unknown): asserts value is string { if (typeof value !== "string") { throw new TypeError("Expected string"); } } // Defined assertion function assertDefined<T>(value: T): asserts value is NonNullable<T> { if (value === undefined || value === null) { throw new Error("Value must be defined"); } } // Practical example with objects interface User { id: string; email: string; profile?: { name: string }; } function assertHasProfile(user: User): asserts user is User & { profile: { name: string } } { if (!user.profile) { throw new Error("User has no profile"); } } function greetUser(user: User) { assertHasProfile(user); // user.profile is guaranteed to exist console.log(`Hello, ${user.profile.name}`); } // Array assertions function assertNonEmpty<T>(arr: T[]): asserts arr is [T, ...T[]] { if (arr.length === 0) throw new Error("Array cannot be empty"); }