The Deep Book

Advanced TypeScript

Master the type system from the inside out

Generics Conditional Types Mapped Types infer Decorators Utility Types Declaration Merging Template Literals
Read
Chapter 01 · Part I

Generics

Generics are the foundation of reusable, type-safe code. They let you write algorithms and data structures that work across many types while preserving the specific type information the compiler needs.

Beyond the Angle Bracket

Most developers meet generics as a simple T placeholder and stop there. But the real power is in how TypeScript resolves type parameters — through inference, constraints, and defaults.

TypeScript
// Generic function — T is inferred from the argument
function identity<T>(value: T): T {
  return value;
}

// TypeScript infers T = string
const result = identity("hello"); // type: string

// Explicit type argument overrides inference
const forced = identity<number>(42);    // type: number

Generic Constraints

The extends keyword constrains what types are accepted. This is not inheritance — it's subtype checking. Any type assignable to the constraint is valid.

TypeScript
// T must have a .length property
function getLength<T extends { length: number }>(arg: T): number {
  return arg.length;
}

getLength("hello");    // ✓  string has .length
getLength([1, 2, 3]);  // ✓  array has .length
getLength(42);         // ✗  number has no .length

// Constraining one type param with another
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"); // type: string
getProperty(user, "age");  // type: number

Generic Defaults

Type parameters can have defaults, making them optional in generic interfaces and classes. This is essential when building extensible APIs.

TypeScript
interface Repository<T, ID = number> {
  findById(id: ID): Promise<T | null>;
  save(entity: T): Promise<T>;
}

// ID defaults to number
interface UserRepo extends Repository<User> {}

// Override ID to string (e.g. UUID)
interface PostRepo extends Repository<Post, string> {}
💡 Best Practice

Prefer inferred type arguments over explicit ones whenever the compiler can figure it out. Explicit arguments are a code smell that your types may be over-constrained or your function signature could be improved.

Variadic Tuple Types

TypeScript 4.0 introduced variadic tuple types — the ability to spread generic type arguments into tuple positions. This enables precise typing of functions like concat, zip, and argument spreading.

TypeScript
// Spread types in tuple position
type Concat<T extends unknown[], U extends unknown[]> = [...T, ...U];

type Result = Concat<[number, string], [boolean]>;
// type Result = [number, string, boolean]

// Practical: Prepend to tuple
type Prepend<T, Tuple extends unknown[]> = [T, ...Tuple];

function addContext<Args extends unknown[]>(
  fn: (...args: Args) => void
): (...args: Prepend<string, Args>) => void {
  return (ctx, ...rest) => fn(...rest);
}
Chapter 02 · Part I

Conditional Types

Conditional types introduce if-else logic into the type level. They're the foundation of the most powerful utility types in TypeScript and enable computation that would otherwise be impossible.

The Ternary of Types

The syntax mirrors JavaScript's ternary operator: T extends U ? X : Y. If T is assignable to U, the type resolves to X, otherwise to Y.

TypeScript
type IsString<T> = T extends string ? "yes" : "no";

type A = IsString<string>;  // "yes"
type B = IsString<number>;  // "no"

// Practical: flatten array types
type Flatten<T> = T extends Array<infer Item> ? Item : T;

type Str = Flatten<string[]>;  // string
type Num = Flatten<number>;   // number  (not an array, falls through)

Distributive Conditional Types

When you pass a union type to a conditional type, TypeScript automatically distributes the condition over each member. This is called distribution and is fundamental to how many built-in types like Exclude and Extract work.

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

// With a union, distribution applies:
type R = ToArray<string | number>;
// type R = string[] | number[]  ← distributed!

// To PREVENT distribution, wrap in a tuple:
type NoDistrib<T> = [T] extends [unknown] ? T[] : never;

type R2 = NoDistrib<string | number>;
// type R2 = (string | number)[]  ← no distribution
⚠ Watch Out

Distribution only occurs when the checked type is a bare type parameter. Wrapping it in a tuple ([T]) disables distribution — useful when you genuinely need to check unions as a whole.

Chained Conditionals

Conditional types can nest to create powerful type-level computations. Think of these as pattern matching over the type system.

TypeScript
type TypeName<T> =
  T extends string   ? "string"   :
  T extends number   ? "number"   :
  T extends boolean  ? "boolean"  :
  T extends null     ? "null"     :
  T extends undefined? "undefined":
  T extends Function ? "function" :
                         "object";

type T1 = TypeName<() => void>; // "function"
type T2 = TypeName<string[]>;      // "object"
Chapter 03 · Part I

Mapped Types

Mapped types let you construct new types by iterating over the keys of an existing type. Combined with modifiers and remapping, they can transform types in remarkably expressive ways.

The for...in of the Type World

The syntax { [K in keyof T]: ... } iterates over every key of T and lets you define the value type for each. All built-in utility types like Partial, Required, and Readonly are implemented this way.

TypeScript
// Implement Partial from scratch
type MyPartial<T> = {
  [K in keyof T]?: T[K];
};

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

// Map over arbitrary string union
type Flags = {
  [K in "read" | "write" | "execute"]: boolean;
};
// { read: boolean; write: boolean; execute: boolean }

Modifiers: +, −, readonly, ?

The + and modifiers add or remove readonly and ? (optional) from mapped properties. This is how Required strips optionality.

TypeScript
// Remove optional and readonly modifiers
type Mutable<T> = {
  -readonly [K in keyof T]-?: T[K];
};

type Frozen = { readonly x?: number; readonly y?: number };
type Thawed = Mutable<Frozen>;
// { x: number; y: number }  ← required and writable

Key Remapping with as

TypeScript 4.1 added the ability to remap keys using as in mapped types. This lets you rename, filter, or transform property names.

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

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

// Filter keys with "as never"
type OnlyStrings<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K];
};
📌 Key Insight

Mapping a key to never removes it from the resulting type entirely. This is the idiomatic way to filter properties in a mapped type.

Chapter 04 · Part I

Template Literal Types

Template literal types bring the power of JavaScript's template literals to the type system. They enable string manipulation at compile time, unlocking a class of APIs previously impossible to type precisely.

String Interpolation in Types

The syntax mirrors template literals exactly — but at the type level. Union types distribute through template literals automatically.

TypeScript
type Greeting = `Hello, ${string}`;
const hi: Greeting = "Hello, world";  // ✓
const bye: Greeting = "Goodbye";      // ✗

// Unions distribute through template literals
type Dir = "top" | "right" | "bottom" | "left";
type Padding = `padding-${Dir}`;
// "padding-top" | "padding-right" | "padding-bottom" | "padding-left"

// Combining two unions creates a cross-product
type Side = "left" | "right";
type Align = "top" | "bottom";
type Corner = `${Side}-${Align}`;
// "left-top" | "left-bottom" | "right-top" | "right-bottom"

Intrinsic String Manipulation Types

TypeScript ships four built-in string utility types for case manipulation:

TypeEffectExample
Uppercase<S>All caps"hello" → "HELLO"
Lowercase<S>All lowercase"HELLO" → "hello"
Capitalize<S>First char upper"hello" → "Hello"
Uncapitalize<S>First char lower"Hello" → "hello"

Building Event Systems

One of the most practical uses of template literal types is creating typed event systems — a common pattern in UI frameworks and state machines.

TypeScript
type EventMap = {
  click: { x: number; y: number };
  focus: { target: HTMLElement };
  input: { value: string };
};

// Generate "onClick", "onFocus", "onInput" from EventMap keys
type EventHandlers = {
  [K in keyof EventMap as `on${Capitalize<string & K>}`]?:
    (event: EventMap[K]) => void;
};
// { onClick?: (e: {x,y}) => void; onFocus?: ...; onInput?: ... }
Chapter 05 · Part II

infer & Type Inference

The infer keyword lets you extract types from within conditional type patterns. It's one of the most powerful tools in TypeScript — used to "take apart" types and pull out their inner pieces.

Declaring Inference Variables

infer introduces a new type variable that TypeScript binds to a matched type. It only works inside extends clauses of conditional types.

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

type Fn = () => { id: number; name: string };
type R = ReturnType<Fn>; // { id: number; name: string }

// Extract first argument type
type FirstArg<T> =
  T extends (first: infer A, ...rest: any[]) => any ? A : never;

type Add = (a: number, b: number) => number;
type FA = FirstArg<Add>; // number

Unwrapping Nested Types

You can use infer recursively to unwrap deeply nested generic types like Promises and arrays.

TypeScript
// Recursively unwrap Promise<Promise<...>>
type Awaited<T> =
  T extends Promise<infer U> ? Awaited<U> : T;

type T1 = Awaited<Promise<string>>;                     // string
type T2 = Awaited<Promise<Promise<number>>>;            // number

// Infer within constructors to get instance type
type InstanceOf<T> =
  T extends new (...args: any[]) => infer I ? I : never;

class Car { drive() {} }
type CarInst = InstanceOf<typeof Car>; // Car
Chapter 06 · Part II

Utility Types Deep Dive

TypeScript's standard library ships a rich set of utility types. Understanding how they're implemented — not just how to use them — turns you from a consumer into a composer.

Transformation
Partial<T>

All properties optional

Transformation
Required<T>

All properties required

Transformation
Readonly<T>

All properties read-only

Selection
Pick<T,K>

Keep only keys K

Selection
Omit<T,K>

Remove keys K

Union Ops
Exclude<T,U>

Remove U from T union

Union Ops
Extract<T,U>

Keep U members in T

Function
ReturnType<F>

Return type of function

Function
Parameters<F>

Param tuple of function

Composing Utilities

The real art lies in composing these utilities. Most real-world type puzzles are solved by chaining two or three utilities together.

TypeScript
interface Config {
  host: string;
  port: number;
  debug: boolean;
  secret: string;
}

// Public config: pick some fields, make them readonly
type PublicConfig = Readonly<Pick<Config, "host" | "port">>;

// Patch type: all optional except id
type Patch<T, Required extends keyof T> =
  Pick<T, Required> & Partial<Omit<T, Required>>;

type UserPatch = Patch<{ id: string; name: string; email: string }, "id">;
// { id: string; name?: string; email?: string }
Chapter 07 · Part II

Narrowing & Type Guards

TypeScript's control flow analysis narrows types automatically in many cases. Understanding exactly how narrowing works — and how to extend it with custom guards — gives you precise control over type safety.

Control Flow Analysis

TypeScript tracks the type of a variable as it flows through conditionals, assignments, and returns. This is called control flow-based type analysis.

TypeScript
function process(val: string | number | null) {
  if (val === null) {
    // val: null
    return;
  }
  // val: string | number
  if (typeof val === "string") {
    // val: string
    console.log(val.toUpperCase());
  } else {
    // val: number
    console.log(val.toFixed(2));
  }
}

User-Defined Type Guards

The return type arg is Type tells TypeScript: "if this function returns true, then narrow arg to Type in the enclosing scope."

TypeScript
interface Dog { kind: "dog"; bark(): void }
interface Cat { kind: "cat"; meow(): void }
type Pet = Dog | Cat;

// Predicate function: narrows based on runtime check
function isDog(pet: Pet): pet is Dog {
  return pet.kind === "dog";
}

function makeSound(pet: Pet) {
  if (isDog(pet)) {
    pet.bark(); // ✓ TypeScript knows pet is Dog here
  } else {
    pet.meow(); // ✓ And Cat here
  }
}

// Assertion function — throws or narrows
function assertIsString(val: unknown): asserts val is string {
  if (typeof val !== "string") throw new TypeError();
}

Discriminated Unions

The most idiomatic narrowing pattern in TypeScript is the discriminated union — a union whose members share a literal type discriminant field.

TypeScript
type Action =
  | { type: "ADD_TODO";   payload: { text: string } }
  | { type: "REMOVE_TODO"; payload: { id: number } }
  | { type: "CLEAR_ALL" };

function reducer(action: Action) {
  switch (action.type) {
    case "ADD_TODO":
      // action.payload: { text: string }
      break;
    case "REMOVE_TODO":
      // action.payload: { id: number }
      break;
    default:
      // Exhaustiveness check: action is never here
      const _exhaust: never = action;
  }
}
✓ Pattern

The exhaustiveness check pattern — assigning to never in the default branch — ensures the compiler catches any future union members you forget to handle. Add this to every switch over a discriminated union.

Chapter 08 · Part III

Declaration Merging

TypeScript allows multiple declarations with the same name to be merged into a single definition. This enables powerful patterns like module augmentation and interface extension that are fundamental to many major libraries.

Interface Merging

Unlike type aliases, interface declarations with the same name merge. This is how you augment third-party types without modifying their source.

TypeScript
interface Window {
  myCustomProp: string;
}

// Now window.myCustomProp is typed!
window.myCustomProp = "hello"; // ✓

// Merge interface in a module augmentation
declare module "express" {
  interface Request {
    user?: { id: string; role: string };
  }
}

Namespace + Class / Function Merging

A namespace can merge with a class or function to add static members or inner types — a pattern used heavily in older codebases and some framework APIs.

TypeScript
function buildValidator(schema: string): Validator { /* ... */ }

// Merge a namespace onto the function
namespace buildValidator {
  export type Options = { strict: boolean };
  export const VERSION = "1.0.0";
}

// Works as both function and namespace
buildValidator("email");
buildValidator.VERSION; // "1.0.0"
Chapter 09 · Part III

Decorators

Decorators are a stage-3 JavaScript proposal (and TypeScript feature) that let you annotate and modify classes, methods, accessors, and properties declaratively. TypeScript 5.0 shipped support for the modern, standardized decorator API.

📌 TypeScript 5.0+

These examples use the modern decorator API (ECMAScript 2023 decorators), not the legacy experimental decorators. Enable with "experimentalDecorators": false or simply use TS 5.0+ without that flag.

Class Decorators

TypeScript
// A class decorator receives the class constructor
function sealed(target: new (...args: any[]) => unknown) {
  Object.seal(target);
  Object.seal(target.prototype);
}

@sealed
class BugReport {
  type = "report";
  constructor(public title: string) {}
}

Method Decorators

TypeScript
// Log execution time of any method
function measure(
  target: unknown,
  context: ClassMethodDecoratorContext
) {
  const name = String(context.name);
  return function(this: unknown, ...args: unknown[]) {
    const start = performance.now();
    const result = (target as Function).call(this, ...args);
    console.log(`${name} took ${performance.now() - start}ms`);
    return result;
  };
}

class Analytics {
  @measure
  processReport(data: ReportData) { /* ... */ }
}
Chapter 10 · Part III

Module Patterns

TypeScript's module system mirrors JavaScript's, but adds layers of type-only imports, ambient modules, and declaration files. Mastering this is critical for authoring libraries and working in large codebases.

Type-Only Imports

The import type syntax imports a type that is completely erased at runtime. This prevents circular dependency issues and signals intent clearly.

TypeScript
// Only imports the type — zero runtime cost
import type { User } from "./user";

// You can also inline type within a regular import
import { type Config, createApp } from "./app";

// Useful for satisfies operator (TS 4.9+)
const config = {
  port: 3000,
  host: "localhost"
} satisfies Config;

Ambient Modules & Declaration Files

For modules with no TypeScript source, you write ambient declarations in .d.ts files to tell the compiler what types exist.

TypeScript (*.d.ts)
// Declare a module for a JS library with no types
declare module "legacy-lib" {
  export function doThing(x: string): number;
  export const version: string;
}

// Wildcard module for file types (e.g. CSS modules)
declare module "*.css" {
  const styles: { [className: string]: string };
  export default styles;
}

// Augment an existing module
declare module "lodash" {
  interface LoDashStatic {
    myCustomUtil(x: string): string;
  }
}

The satisfies Operator

Introduced in TypeScript 4.9, satisfies validates that a value matches a type while keeping the most specific inferred type — the best of both worlds.

TypeScript
type Routes = Record<string, { path: string; title: string }>;

// With "as Routes" — loses specific key types
const routes1 = {
  home: { path: "/", title: "Home" }
} as Routes;
routes1.nonexistent; // No error! Type is Record<string,...>

// With "satisfies" — validates AND keeps specific keys
const routes2 = {
  home: { path: "/", title: "Home" }
} satisfies Routes;
routes2.nonexistent; // ✗ Error! Key doesn't exist
routes2.home.path;   // ✓ TypeScript knows this is "/"
✓ Rule of Thumb

Prefer satisfies over type annotations for object literals when you want both validation and precise inference. Use annotations when you want the broader type to flow forward through your code.

↑ Back to Cover TypeScript Docs ↗