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.
// 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.
// 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.
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.
// 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.
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.
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.
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.
// 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.
// 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.
// 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.
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:
| Type | Effect | Example |
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.
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.
// 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.
// 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.
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.
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."
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.
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.
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.
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
// 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
// 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.
// 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.
// 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.
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.