Steven's Knowledge

TypeScript Advanced

The type system as a programming language -- conditional types, generics, utility types, and patterns for production TypeScript

TypeScript Advanced

TypeScript's type system is not just annotations bolted onto JavaScript. It is a full programming language that operates at compile time -- with conditionals, loops (mapped types), pattern matching (template literal types), and recursion. Understanding this layer is what separates someone who adds : string annotations from someone who designs type-safe APIs that catch entire categories of bugs before runtime.

This page assumes you are comfortable with basic TypeScript. We are going deep into the type system.

Conditional Types

Conditional types are if/else for the type system. The syntax mirrors the ternary operator:

type IsString<T> = T extends string ? true : false;

type A = IsString<"hello">;  // true
type B = IsString<42>;       // false

They become powerful when combined with infer, which lets you extract types from other types:

// Extract the return type of a function
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

type Result = MyReturnType<() => string>;  // string

// Extract the element type of an array
type ElementOf<T> = T extends (infer E)[] ? E : never;

type Item = ElementOf<string[]>;  // string

// Extract promise resolution type, recursively
type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T;

type Final = Awaited<Promise<Promise<number>>>;  // number

Distributive Conditional Types

When a conditional type acts on a union, it distributes over each member:

type ToArray<T> = T extends any ? T[] : never;

type Result = ToArray<string | number>;  // string[] | number[]
// NOT (string | number)[]

// To prevent distribution, wrap in a tuple:
type ToArrayNonDist<T> = [T] extends [any] ? T[] : never;

type Result2 = ToArrayNonDist<string | number>;  // (string | number)[]

Mapped Types

Mapped types iterate over keys to create new types. They are the for loop of the type system:

// Make all properties optional
type MyPartial<T> = {
    [K in keyof T]?: T[K];
};

// Make all properties readonly
type MyReadonly<T> = {
    readonly [K in keyof T]: T[K];
};

// Remap keys with `as`
type Getters<T> = {
    [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

interface Person {
    name: string;
    age: number;
}

type PersonGetters = Getters<Person>;
// { getName: () => string; getAge: () => number; }

Key Remapping and Filtering

// Remove properties of a certain type
type OmitByType<T, U> = {
    [K in keyof T as T[K] extends U ? never : K]: T[K];
};

interface Mixed {
    name: string;
    age: number;
    active: boolean;
}

type StringsRemoved = OmitByType<Mixed, string>;
// { age: number; active: boolean; }

Template Literal Types

String manipulation at the type level:

type EventName<T extends string> = `on${Capitalize<T>}`;

type ClickEvent = EventName<"click">;  // "onClick"

// Parse route parameters
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/:userId/posts/:postId">;
// "userId" | "postId"

Generics Deep Dive

Constraints

Generics without constraints are rarely useful. Constrain them to communicate intent:

// Bad: T is unconstrained, offers no type safety on the property access
function getProperty<T>(obj: T, key: string): any {
    return (obj as any)[key];
}

// Good: key is constrained to actual keys of T
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
    return obj[key];
}

const user = { name: "Alice", age: 30 };
getProperty(user, "name");   // string
getProperty(user, "email");  // Error: "email" is not in keyof typeof user

Default Type Parameters

type ApiResponse<T = unknown, E = Error> = {
    data: T | null;
    error: E | null;
    status: number;
};

// Uses defaults
const res: ApiResponse = { data: null, error: null, status: 200 };

// Override just the first
const userRes: ApiResponse<User> = { data: user, error: null, status: 200 };

Variance

Understanding variance prevents subtle type bugs:

// Covariant: readonly arrays
type ReadonlyAnimal = readonly Animal[];
type ReadonlyDog = readonly Dog[];
// ReadonlyDog extends ReadonlyAnimal (Dog[] is assignable to Animal[])

// Contravariant: function parameters
type AnimalHandler = (a: Animal) => void;
type DogHandler = (d: Dog) => void;
// AnimalHandler extends DogHandler (wider input is assignable to narrower)

// Use `in` and `out` modifiers (TS 4.7+) to be explicit:
type Consumer<in T> = (value: T) => void;
type Producer<out T> = () => T;

Discriminated Unions

The single most useful pattern in TypeScript for modeling domain states:

type Result<T, E = Error> =
    | { ok: true; value: T }
    | { ok: false; error: E };

function processResult(result: Result<User>) {
    if (result.ok) {
        // TypeScript knows: result.value is User
        console.log(result.value.name);
    } else {
        // TypeScript knows: result.error is Error
        console.error(result.error.message);
    }
}

// Exhaustiveness checking with never
type Shape =
    | { kind: "circle"; radius: number }
    | { kind: "rectangle"; width: number; height: number }
    | { kind: "triangle"; base: number; height: number };

function area(shape: Shape): number {
    switch (shape.kind) {
        case "circle":
            return Math.PI * shape.radius ** 2;
        case "rectangle":
            return shape.width * shape.height;
        case "triangle":
            return 0.5 * shape.base * shape.height;
        default:
            // If a new shape is added, this line errors
            const _exhaustive: never = shape;
            return _exhaustive;
    }
}

Branded Types

Prevent mixing structurally identical but semantically different values:

type Brand<T, B extends string> = T & { readonly __brand: B };

type USD = Brand<number, "USD">;
type EUR = Brand<number, "EUR">;
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;

function chargeUSD(amount: USD): void { /* ... */ }

const price = 19.99 as USD;
const euroPrice = 19.99 as EUR;

chargeUSD(price);       // OK
chargeUSD(euroPrice);   // Error: EUR is not assignable to USD
chargeUSD(19.99);       // Error: number is not assignable to USD

Type Guards

is Type Predicates

interface Cat { meow(): void; }
interface Dog { bark(): void; }

function isCat(animal: Cat | Dog): animal is Cat {
    return "meow" in animal;
}

function handleAnimal(animal: Cat | Dog) {
    if (isCat(animal)) {
        animal.meow();  // TypeScript knows it's a Cat
    } else {
        animal.bark();  // TypeScript knows it's a Dog
    }
}

asserts Type Predicates

function assertDefined<T>(
    value: T | null | undefined,
    message: string
): asserts value is T {
    if (value == null) {
        throw new Error(message);
    }
}

function processUser(user: User | null) {
    assertDefined(user, "User must exist");
    // After this line, TypeScript knows user is User
    console.log(user.name);
}

The satisfies Operator

Validates that an expression matches a type without widening it:

type ColorMap = Record<string, string | [number, number, number]>;

// Without satisfies: type is widened to ColorMap
const colors1: ColorMap = {
    red: [255, 0, 0],
    green: "#00ff00",
};
colors1.red.map(x => x);  // Error: string | number[] has no .map

// With satisfies: validates against ColorMap but keeps the narrow type
const colors2 = {
    red: [255, 0, 0],
    green: "#00ff00",
} satisfies ColorMap;
colors2.red.map(x => x);      // OK: TypeScript knows it's a tuple
colors2.green.toUpperCase();   // OK: TypeScript knows it's a string

Const Assertions

Lock down literal types and prevent widening:

// Without as const: types are widened
const routes1 = {
    home: "/",
    about: "/about",
};
// type: { home: string; about: string }

// With as const: literal types preserved
const routes2 = {
    home: "/",
    about: "/about",
} as const;
// type: { readonly home: "/"; readonly about: "/about" }

// Useful for deriving union types
type Route = (typeof routes2)[keyof typeof routes2];  // "/" | "/about"

Essential Utility Types

A quick reference for the utility types you will use most:

// Pick: select specific properties
type UserPreview = Pick<User, "id" | "name">;

// Omit: exclude specific properties
type CreateUser = Omit<User, "id" | "createdAt">;

// Partial: make all properties optional
type UpdateUser = Partial<User>;

// Required: make all properties required
type CompleteUser = Required<Partial<User>>;

// Record: construct an object type with keys K and values V
type StatusMap = Record<"active" | "inactive" | "banned", User[]>;

// ReturnType: extract return type of a function
type ConfigResult = ReturnType<typeof loadConfig>;

// Parameters: extract parameter types as a tuple
type FetchParams = Parameters<typeof fetch>;

// Exclude / Extract: filter union members
type StringOrNumber = string | number | boolean;
type OnlyStrNum = Exclude<StringOrNumber, boolean>;  // string | number

Type-Checking Performance

As your codebase grows, type-checking speed matters:

  • Avoid deep recursive types. They cause exponential checking time. Set depth limits.
  • Prefer interfaces over type aliases for object shapes -- interfaces are cached by name, type aliases are re-evaluated.
  • Use @ts-expect-error over @ts-ignore -- it errors when the suppression is no longer needed.
  • Run tsc --generateTrace to find slow types in your project.
  • Avoid union types with hundreds of members. They cause quadratic assignability checks.

Full-Stack Patterns

When you control both client and server, share types across the boundary:

// shared/types.ts -- used by both frontend and backend
export interface ApiEndpoints {
    "GET /users": { response: User[]; query: { page: number } };
    "GET /users/:id": { response: User; params: { id: string } };
    "POST /users": { response: User; body: CreateUserInput };
}

// Type-safe API client
type Method = "GET" | "POST" | "PUT" | "DELETE";

type EndpointConfig<T extends keyof ApiEndpoints> = ApiEndpoints[T];

async function apiCall<T extends keyof ApiEndpoints>(
    endpoint: T,
    config: Omit<EndpointConfig<T>, "response">
): Promise<EndpointConfig<T>["response"]> {
    // Implementation uses the endpoint string to derive method and path
    // ...
}

// Usage: fully type-safe
const users = await apiCall("GET /users", { query: { page: 1 } });
// users is User[]

The TypeScript type system rewards investment. The patterns above are not academic exercises -- they are how production codebases prevent entire categories of bugs at compile time rather than discovering them in production.

On this page