TypeScript Advanced Patterns in 2026: Type-Level Programming That Actually Scales



TypeScript Advanced Patterns in 2026: Type-Level Programming That Actually Scales

TypeScript turned 14 this year, and its type system has evolved from “JavaScript with type annotations” to a genuinely powerful constraint language. Teams that master advanced TypeScript write code that catches entire categories of bugs at compile time — not at 3 AM during an incident.

This guide covers the advanced patterns that separate TypeScript power users from the rest: type-level programming, conditional types, template literals, and the architectural patterns that make large codebases maintainable.

Code editor with TypeScript on screen, dark theme Photo by Pankaj Patel on Unsplash


Template Literal Types: String Manipulation at the Type Level

Template literal types let you construct string types from other types — enabling APIs that are both flexible and type-safe.

Basic Pattern

type EventName = "click" | "focus" | "blur";
type Handler = `on${Capitalize<EventName>}`;
// type Handler = "onClick" | "onFocus" | "onBlur"

// Practical: typed CSS property setters
type CSSProperty = "margin" | "padding" | "border";
type Side = "Top" | "Right" | "Bottom" | "Left";
type SpacingProperty = `${CSSProperty}${Side}`;
// "marginTop" | "marginRight" | ... | "borderLeft" (12 combinations auto-generated)

const setStyle = (prop: SpacingProperty, value: string): void => {
    document.body.style[prop] = value;  // Fully type-safe
};

setStyle("marginTop", "16px");     // ✓
setStyle("paddingLeft", "8px");    // ✓
setStyle("marginDiagonal", "4px"); // ✗ Type error!

Event System with Typed Payloads

// Domain events
interface EventMap {
    "user:created": { userId: string; email: string };
    "user:deleted": { userId: string };
    "order:placed": { orderId: string; total: number; items: string[] };
    "order:shipped": { orderId: string; trackingId: string };
    "payment:completed": { paymentId: string; amount: number };
}

type EventName = keyof EventMap;

// Type-safe event emitter
class TypedEventEmitter {
    private handlers: Partial<{
        [K in EventName]: Array<(payload: EventMap[K]) => void>
    }> = {};
    
    on<K extends EventName>(
        event: K,
        handler: (payload: EventMap[K]) => void
    ): void {
        (this.handlers[event] ??= []).push(handler as any);
    }
    
    emit<K extends EventName>(event: K, payload: EventMap[K]): void {
        this.handlers[event]?.forEach(h => h(payload));
    }
}

const bus = new TypedEventEmitter();

// Fully type-safe — payload type is inferred from event name
bus.on("order:placed", ({ orderId, total, items }) => {
    console.log(`Order ${orderId}: $${total} for ${items.length} items`);
});

bus.emit("order:placed", {
    orderId: "ord_123",
    total: 99.99,
    items: ["item1", "item2"],
    // If you add an unknown field → Type error
});

Conditional Types and infer: The Type System’s Control Flow

Basic Conditional Types

// Extract the element type from an array
type ElementType<T> = T extends Array<infer Item> ? Item : never;

type StringArrayElement = ElementType<string[]>;  // string
type NumberArrayElement = ElementType<number[]>;  // number
type NotArray = ElementType<string>;              // never

// Unwrap nested promises
type Awaited<T> = T extends Promise<infer U>
    ? U extends Promise<any>
        ? Awaited<U>  // Recursively unwrap
        : U
    : T;

type Result1 = Awaited<Promise<string>>;                    // string
type Result2 = Awaited<Promise<Promise<number>>>;           // number
type Result3 = Awaited<string>;                             // string

Advanced: Extract Function Signatures

// Extract return type of async functions
type AsyncReturnType<T extends (...args: any) => Promise<any>> =
    T extends (...args: any) => Promise<infer R> ? R : never;

async function fetchUser(id: string): Promise<{ id: string; name: string }> {
    return { id, name: "Alice" };
}

type User = AsyncReturnType<typeof fetchUser>;
// type User = { id: string; name: string }

// Extract specific parameter by position
type FirstParam<T extends (...args: any) => any> =
    T extends (first: infer F, ...rest: any[]) => any ? F : never;

function greet(name: string, greeting: string): string {
    return `${greeting}, ${name}!`;
}

type GreetFirstParam = FirstParam<typeof greet>;  // string

Pattern: Type-Safe Builder with Conditional Return Types

interface QueryState {
    hasWhere: boolean;
    hasLimit: boolean;
    hasOrderBy: boolean;
}

type QueryResult<S extends QueryState> =
    S["hasWhere"] extends true
        ? S["hasLimit"] extends true
            ? { rows: unknown[]; count: number }  // where + limit = paginated result
            : { rows: unknown[] }                 // where only
        : { rows: unknown[]; total: number };     // no where = full scan with total

class TypedQueryBuilder<S extends QueryState = {
    hasWhere: false;
    hasLimit: false;
    hasOrderBy: false;
}> {
    private _where?: string;
    private _limit?: number;
    private _orderBy?: string;
    
    where(condition: string): TypedQueryBuilder<S & { hasWhere: true }> {
        const next = new TypedQueryBuilder<S & { hasWhere: true }>();
        next._where = condition;
        next._limit = this._limit;
        return next;
    }
    
    limit(n: number): TypedQueryBuilder<S & { hasLimit: true }> {
        const next = new TypedQueryBuilder<S & { hasLimit: true }>();
        next._where = this._where;
        next._limit = n;
        return next;
    }
    
    async execute(): Promise<QueryResult<S>> {
        // Implementation...
        return {} as QueryResult<S>;
    }
}

const builder = new TypedQueryBuilder();

// Different result types based on builder chain!
const full = await builder.execute();
// type: { rows: unknown[]; total: number }

const filtered = await builder.where("active = true").execute();
// type: { rows: unknown[] }

const paginated = await builder.where("active = true").limit(20).execute();
// type: { rows: unknown[]; count: number }

Mapped Types: Transforming Type Structures

// Make all properties optional and nullable
type DeepPartialNullable<T> = {
    [K in keyof T]?: T[K] extends object
        ? DeepPartialNullable<T[K]>
        : T[K] | null;
};

// Create Read/Write variants
type Mutable<T> = {
    -readonly [K in keyof T]: T[K];  // Remove readonly
};

type Readonly<T> = {
    readonly [K in keyof T]: T[K];   // Add readonly
};

// Filter keys by value type
type KeysOfType<T, V> = {
    [K in keyof T]: T[K] extends V ? K : never
}[keyof T];

interface User {
    id: number;
    name: string;
    email: string;
    age: number;
    isActive: boolean;
}

type StringKeys = KeysOfType<User, string>;   // "name" | "email"
type NumberKeys = KeysOfType<User, number>;   // "id" | "age"
type BooleanKeys = KeysOfType<User, boolean>; // "isActive"

// Practical: only allow updating string fields
function updateStringFields(
    user: User,
    updates: Pick<User, StringKeys>
): User {
    return { ...user, ...updates };
}

updateStringFields(user, { name: "Bob", email: "bob@example.com" }); // ✓
updateStringFields(user, { age: 30 });  // ✗ Type error!

The satisfies Operator: Infer Without Widening

The satisfies operator (TS 4.9+) validates a value against a type while preserving the literal type — solving the “I want type checking but also autocomplete on the narrowed type” problem.

type Color = string | { r: number; g: number; b: number };
type Palette = Record<string, Color>;

// Without satisfies: TypeScript widens everything to Color
const palette1: Palette = {
    red: "#ff0000",
    blue: { r: 0, g: 0, b: 255 },
};
palette1.red.toUpperCase();  // ✗ Error! TypeScript thinks red could be an object

// With satisfies: TypeScript keeps the narrowed types
const palette2 = {
    red: "#ff0000",
    blue: { r: 0, g: 0, b: 255 },
} satisfies Palette;

palette2.red.toUpperCase();   // ✓ TypeScript knows red is a string
palette2.blue.r;              // ✓ TypeScript knows blue is an object

// Practical: config objects
type Config = {
    port: number;
    host: string;
    features: string[];
    database: {
        url: string;
        poolSize: number;
    };
};

const config = {
    port: 3000,
    host: "localhost",
    features: ["auth", "payments"] as const,
    database: {
        url: "postgresql://localhost/mydb",
        poolSize: 10,
    },
} satisfies Config;

// config.features is "auth" | "payments"[], not string[]
// config.port is 3000, not just number

Branded Types: Preventing Primitive Confusion

Branded types create distinct types from the same primitive — preventing you from accidentally using a UserId where an OrderId is expected.

// The brand technique
declare const __brand: unique symbol;
type Brand<T, B extends string> = T & { [__brand]: B };

// Define branded primitives
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type ProductId = Brand<string, "ProductId">;
type Email = Brand<string, "Email">;
type Dollars = Brand<number, "Dollars">;
type Cents = Brand<number, "Cents">;

// Constructor functions with validation
function UserId(id: string): UserId {
    if (!id.startsWith("usr_")) throw new Error(`Invalid UserId: ${id}`);
    return id as UserId;
}

function Email(email: string): Email {
    if (!/^[^@]+@[^@]+\.[^@]+$/.test(email)) {
        throw new Error(`Invalid email: ${email}`);
    }
    return email as Email;
}

function Cents(amount: number): Cents {
    if (!Number.isInteger(amount) || amount < 0) {
        throw new Error(`Cents must be non-negative integer: ${amount}`);
    }
    return amount as Cents;
}

// Functions that only accept the right type
function getUser(userId: UserId): Promise<User> {
    return db.users.findOne({ id: userId });
}

function chargeUser(userId: UserId, amount: Cents): Promise<void> {
    return payments.charge(userId, amount);
}

// Usage — impossible to mix up
const userId = UserId("usr_abc123");
const orderId = "ord_xyz789" as OrderId;  // Can cast if already validated

getUser(userId);   // ✓
getUser(orderId);  // ✗ Type error: OrderId is not assignable to UserId

chargeUser(userId, Cents(999));   // ✓ — $9.99 in cents
chargeUser(userId, 999 as Dollars); // ✗ Type error: Dollars ≠ Cents

Discriminated Unions: The Pattern for State Machines

// API response states
type ApiState<T> =
    | { status: "idle" }
    | { status: "loading" }
    | { status: "success"; data: T; timestamp: Date }
    | { status: "error"; error: string; retryAfter?: Date };

// The compiler forces you to handle all cases
function renderUser(state: ApiState<User>): string {
    switch (state.status) {
        case "idle":    return "Not loaded";
        case "loading": return "Loading...";
        case "success": return `Hello, ${state.data.name}!`;
        case "error":   return `Error: ${state.error}`;
        // No default needed — TypeScript knows all cases are covered
    }
}

// Advanced: narrow by multiple discriminants
type PaymentState =
    | { kind: "pending"; paymentId: string }
    | { kind: "processing"; paymentId: string; processor: string }
    | { kind: "completed"; paymentId: string; receiptUrl: string; completedAt: Date }
    | { kind: "failed"; paymentId: string; reason: string; retryable: boolean }
    | { kind: "refunded"; paymentId: string; refundAmount: number };

// Exhaustive check utility — compile error if you miss a case
function assertNever(x: never): never {
    throw new Error(`Unexpected value: ${JSON.stringify(x)}`);
}

function getPaymentMessage(payment: PaymentState): string {
    switch (payment.kind) {
        case "pending":    return `Payment ${payment.paymentId} pending`;
        case "processing": return `Processing via ${payment.processor}`;
        case "completed":  return `Done! Receipt: ${payment.receiptUrl}`;
        case "failed":     return `Failed: ${payment.reason}`;
        case "refunded":   return `Refunded $${payment.refundAmount}`;
        default:           return assertNever(payment);  // Compile error if you add a new state!
    }
}

Module Augmentation: Extending Third-Party Types

// Extend Express Request to include your auth context
import "express";

declare module "express" {
    interface Request {
        user?: {
            id: string;
            email: string;
            roles: string[];
        };
        requestId: string;
        startTime: number;
    }
}

// Now req.user is typed everywhere in your Express app
app.get("/profile", (req, res) => {
    if (!req.user) {
        return res.status(401).json({ error: "Unauthorized" });
    }
    res.json({ email: req.user.email }); // Fully typed ✓
});

// Extend Axios to include your API-specific response wrapper
import "axios";

declare module "axios" {
    interface AxiosResponse<T = any> {
        parsed: T;           // Your parsed response
        requestId: string;   // Correlation ID from headers
    }
}

Real-World Pattern: Type-Safe API Client

Developer coding on laptop with multiple screens showing code Photo by Vladimir Kudinov on Unsplash

// Define your API schema
interface ApiSchema {
    "/users": {
        GET: { response: User[]; query: { role?: string; limit?: number } };
        POST: { body: { name: string; email: string }; response: User };
    };
    "/users/:id": {
        GET: { response: User; params: { id: string } };
        PUT: { body: Partial<User>; response: User; params: { id: string } };
        DELETE: { response: void; params: { id: string } };
    };
    "/orders": {
        GET: { response: Order[]; query: { userId?: string; status?: string } };
        POST: { body: CreateOrderInput; response: Order };
    };
}

type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
type ApiPath = keyof ApiSchema;
type MethodsFor<P extends ApiPath> = keyof ApiSchema[P] & HttpMethod;

// Type-safe API client
class TypedApiClient {
    async request<
        P extends ApiPath,
        M extends MethodsFor<P>
    >(
        path: P,
        method: M,
        options: Omit<ApiSchema[P][M], "response">
    ): Promise<ApiSchema[P][M] extends { response: infer R } ? R : never> {
        // Implementation...
        const url = this.buildUrl(path, (options as any).params);
        const res = await fetch(url, {
            method,
            headers: { "Content-Type": "application/json" },
            body: "body" in options ? JSON.stringify((options as any).body) : undefined,
        });
        return res.json();
    }
    
    private buildUrl(path: string, params?: Record<string, string>): string {
        return params
            ? path.replace(/:(\w+)/g, (_, key) => params[key] ?? key)
            : path;
    }
}

const api = new TypedApiClient();

// Fully type-safe — TypeScript knows the return type from path + method
const users = await api.request("/users", "GET", {
    query: { role: "admin", limit: 10 }
});
// users: User[]

const user = await api.request("/users/:id", "GET", {
    params: { id: "usr_123" }
});
// user: User

// Compile errors catch mistakes before runtime:
await api.request("/users/:id", "POST", { params: { id: "1" } }); // ✗ No POST on /users/:id
await api.request("/users", "DELETE", {});  // ✗ No DELETE on /users

Summary: TypeScript Power User Checklist

The advanced TypeScript patterns that actually matter in production:

  1. Template literal types — Generate string union types from combinations; type event names, CSS properties, API routes
  2. infer in conditional types — Extract types from generic shapes; the foundation of utility types
  3. Mapped types with filtering — Transform entire type structures programmatically
  4. satisfies operator — Get type checking without losing literal type inference
  5. Branded types — Prevent primitive confusion for IDs, money, validated strings
  6. Discriminated unions + exhaustive checks — Model state machines correctly; never miss a case
  7. Module augmentation — Safely extend third-party types to fit your domain
  8. Type-safe builders and APIs — Make invalid states unrepresentable at compile time

The goal isn’t clever type gymnastics — it’s catching real bugs at compile time, enabling better IDE support, and making refactoring safe. Each pattern here has a concrete payoff: fewer runtime errors, more confident refactoring, faster onboarding for new engineers.

Start with branded types and discriminated unions. They give you the most immediate value with the least complexity. Layer in the rest as your codebase grows.


Tags: TypeScript, Type System, Advanced Patterns, Template Literal Types, Conditional Types, Branded Types, Software Engineering

이 글이 도움이 되셨다면 공감 및 광고 클릭을 부탁드립니다 :)