Getting Started
Hello World & Compilation
TypeScript files use the .ts extension. The tsc compiler transpiles TS to JS, erasing all type annotations at runtime. Use --strict for maximum type safety. ts-node or bun can run .ts files directly without a separate compile step.
// hello.ts
const message: string = "Hello, TypeScript!";
console.log(message);
// Compile to JavaScript (type annotations erased):
// tsc hello.ts -> hello.js
// tsc --strict hello.ts // enable all strict checks
// tsc --watch hello.ts // recompile on change
// Run directly with ts-node or bun:
// ts-node hello.tstsconfig.json
tsconfig.json configures the TypeScript compiler. 'strict: true' enables noImplicitAny, strictNullChecks, strictFunctionTypes, and more. 'target' controls the output JS version. 'esModuleInterop' enables default imports from CommonJS modules like Node's built-ins.
{
"compilerOptions": {
"target": "ES2020", // JS version to emit
"module": "ESNext", // module system
"strict": true, // enable all strict checks
"outDir": "./dist", // output directory
"rootDir": "./src", // source root
"esModuleInterop": true, // allow default imports from CJS
"skipLibCheck": true, // skip .d.ts type checking
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}Type Annotations & Inference
Type annotations explicitly specify a variable's type. TypeScript can also infer types from values. Use explicit annotations for function signatures and public APIs; rely on inference for local variables. Avoid 'any' — it opts out of type checking entirely.
// Explicit type annotations
let count: number = 0;
const name: string = "Alice";
let isDone: boolean = true;
let ids: number[] = [1, 2, 3];
let tuple: [string, number] = ["age", 30];
// Type inference (let TS figure it out)
let age = 30; // inferred as number
let items = [1, 2, 3]; // inferred as number[]
let mixed = [1, "a"]; // inferred as (string | number)[]
// any disables type checking (avoid!)
let anything: any = 42;
anything = "string"; // no errorStrict Mode Checks
Strict mode enables critical checks: strictNullChecks (null/undefined not assignable to other types), noImplicitAny (parameters must have types), strictPropertyInitialization (class fields must be initialized). Use '!' (definite assignment) when you're sure a field will be set later.
// strictNullChecks: null/undefined not assignable to other types
let n: string = null; // Error in strict mode
// noImplicitAny: must annotate parameters
function greet(name) { } // Error: implicit any
// strictPropertyInitialization
class User {
name: string; // Error: not initialized
constructor() {}
}
// Fix: initialize or use definite assignment
class Fixed {
name!: string; // definite assignment assertion
age: number = 0;
}Declaration Files (.d.ts)
Declaration files (.d.ts) provide types for JavaScript libraries without TypeScript definitions. 'declare' tells the compiler a variable/function exists at runtime. Use @types packages from DefinitelyTyped for popular libraries (e.g., @types/node, @types/react).
// types.d.ts - type declarations only, no implementation
declare module "my-lib" {
export function greet(name: string): string;
export const version: string;
}
// global.d.ts - extend global scope
declare global {
interface Window {
myApp: { version: string };
}
}
// Usage in .ts files:
// import { greet } from "my-lib"; // now typed
// Install @types packages: npm i -D @types/node @types/reactBasic Types
Primitives & Special Types
TypeScript primitives: string, number, boolean, bigint, symbol. 'void' indicates a function returns no value. 'never' represents values that never occur — functions that throw or run forever. Use 'never' for exhaustive checks in switch statements.
let str: string = "hello";
let num: number = 42;
let bool: boolean = true;
let big: bigint = 100n;
let sym: symbol = Symbol("id");
// void: function returns nothing
function log(msg: string): void { console.log(msg); }
// never: function never returns
function fail(msg: string): never { throw new Error(msg); }
function infinite(): never { while (true) {} }Arrays & Tuples
Arrays use either T[] or Array<T> syntax. ReadonlyArray prevents mutations. Tuples are fixed-length arrays with specific types at each index — useful for key-value pairs or CSV-like data. Labeled tuples improve readability with named positions.
// Two syntaxes for arrays
let nums: number[] = [1, 2, 3];
let strs: Array<string> = ["a", "b"];
// ReadonlyArray (immutable)
const ro: ReadonlyArray<number> = [1, 2, 3];
// ro.push(4); // Error: not mutable
// Tuple (fixed length, known types)
let tuple: [string, number] = ["Alice", 30];
let name = tuple[0]; // string
let age = tuple[1]; // number
// Labeled tuple elements (TS 4.0+)
let entry: [name: string, age: number] = ["Bob", 25];Enums
Enums define a set of named constants. String enums are recommended for debugging (values are readable in output). Numeric enums support reverse mapping. 'const enum' is erased at compile time (inlined) for zero runtime cost. Prefer union types for simple cases.
// Numeric enum (default starts at 0)
enum Direction { Up, Down, Left, Right }
let d: Direction = Direction.Up; // 0
// String enum (recommended for readability)
enum Status {
Pending = "PENDING",
Success = "SUCCESS",
Failed = "FAILED",
}
// Reverse mapping (numeric only)
console.log(Direction[0]); // "Up"
// Const enum (inlined, no runtime object)
const enum Color { Red, Green, Blue }
let c = Color.Red; // compiles to: let c = 0;any vs unknown vs never
'any' disables all type checking — avoid it. 'unknown' is the type-safe alternative: you must narrow it (via typeof, instanceof) before use. 'never' represents values that never occur, used for exhaustiveness checking in switch statements to catch missing cases at compile time.
// any: opt out of type checking (dangerous!)
let a: any = 42;
a = "string"; // OK
a.toUpperCase(); // OK (no check, may fail at runtime)
// unknown: type-safe alternative to any
let u: unknown = 42;
// u.toUpperCase(); // Error: unknown type
if (typeof u === "string") {
u.toUpperCase(); // OK after narrowing
}
// never: impossible value (exhaustiveness check)
type Shape = "circle" | "square";
function area(s: Shape) {
switch (s) {
case "circle": return Math.PI;
case "square": return 1;
default:
const _exhaustive: never = s; // Error if case missing
}
}Type Assertions
Type assertions tell the compiler 'trust me, I know the type'. Use 'as' syntax. The non-null assertion (!) tells TS a value isn't null/undefined. 'as const' makes all properties readonly literals — useful for config objects and Redux action types. Assertions don't change runtime behavior.
// as syntax (preferred, works in .tsx)
let val: unknown = "hello";
let len: number = (val as string).length;
// Non-null assertion (!)
let el = document.querySelector("#app")!;
el.innerHTML = "Hi"; // el is HTMLElement, not null
// Double assertion (for unsafe casts)
let value = "42" as unknown as number;
// const assertion (literal types)
const req = { method: "GET", url: "/api" } as const;
// req.method has type "GET" (not string)
// req is readonlyLiteral & Union Types
Literal types restrict a value to a specific string, number, or boolean. Combined with unions, they create precise types like direction or HTTP methods. Template literal types (TS 4.1+) build string types from other types — powerful for generating type-safe keys and event names.
// String literal types
let direction: "left" | "right" | "up" | "down";
direction = "left"; // OK
// direction = "sideways"; // Error
// Numeric literal types
let dice: 1 | 2 | 3 | 4 | 5 | 6;
dice = 4; // OK
// Boolean literal
let flag: true = true;
// Template literal types (TS 4.1+)
type Color = "red" | "blue";
type Size = "small" | "large";
type Variant = `${Size}-${Color}`;
// "small-red" | "small-blue" | "large-red" | "large-blue"Interfaces & Objects
Interface Basics
Interfaces describe the shape of objects. '?' marks optional properties (may be undefined). 'readonly' prevents reassignment after initialization. Interfaces are compile-time only — they're erased in the output JavaScript. Use them to define contracts for objects and classes.
interface User {
id: number;
name: string;
email?: string; // optional property
readonly createdAt: Date; // immutable
}
const user: User = {
id: 1,
name: "Alice",
createdAt: new Date(),
};
// user.createdAt = new Date(); // Error: readonly
// user.email; // string | undefinedIndex Signatures
Index signatures allow objects with arbitrary keys of a given type. All property values must be assignable to the index type. Useful for dictionaries, caches, and dynamic data. Combine with known properties for typed configs with extra options.
// Index signature: arbitrary string keys
interface StringMap {
[key: string]: string;
}
const dict: StringMap = {
name: "Alice",
city: "NYC",
// count: 42, // Error: value must be string
};
// Mixed: known + index signature
interface Config {
name: string;
[key: string]: string | number;
}
// Readonly index signature
interface ReadonlyMap {
readonly [key: string]: number;
}Extending Interfaces
Interfaces can extend one or more other interfaces, combining their members. This enables composition and code reuse. Unlike classes, interfaces support multiple inheritance. When you implement an interface, the class must provide all required members.
interface Animal {
name: string;
eat(): void;
}
interface Pet extends Animal {
owner: string;
play(): void;
}
interface Swimmer {
swim(): void;
}
// Multiple inheritance
interface Duck extends Pet, Swimmer {
quack(): void;
}
const duck: Duck = {
name: "Donald",
owner: "Walt",
eat() {},
play() {},
swim() {},
quack() {},
};Function Types in Interfaces
Interfaces can describe function signatures, enabling type-safe callbacks. Hybrid interfaces (callable + properties) are used for jQuery-style functions that also have methods. This pattern is common in libraries that return functions with attached helpers.
interface SearchFn {
(source: string, keyword: string): boolean;
}
const contains: SearchFn = (src, kw) => src.includes(kw);
console.log(contains("hello world", "world")); // true
// Interface with mixed members (hybrid)
interface Counter {
(start: number): void; // callable
count: number; // property
reset(): void; // method
}
// Function with properties (jQuery-style)
const counter: any = (n: number) => { counter.count = n; };
counter.count = 0;
counter.reset = () => { counter.count = 0; };Interface vs Type Alias
Interfaces support declaration merging (same-name interfaces combine), better error messages, and are preferred for object/class shapes. Type aliases are more flexible (can represent unions, primitives, tuples) but can't be merged. Use interfaces for extensible APIs, type aliases for unions and computed types.
// Interface: extendable, better error messages
interface Window { title: string; }
interface Window { size: number; } // declaration merging
const w: Window = { title: "App", size: 800 };
// Type alias: more flexible (unions, primitives, etc.)
type ID = string | number;
type Callback<T> = (value: T) => void;
// Both can describe object shapes
interface UserI { name: string; }
type UserT = { name: string; };
// Use interface for objects/classes, type for unions/aliasesOptional Chaining & Nullish Coalescing
Optional chaining (?.) safely accesses nested properties — returns undefined instead of throwing if any link is null/undefined. Nullish coalescing (??) provides a default only for null/undefined (not 0 or ''). These operators dramatically reduce verbose null-checking code.
interface User {
profile?: {
address?: {
city?: string;
};
};
}
const user: User = {};
// Optional chaining (?.) - safe property access
const city = user.profile?.address?.city; // string | undefined
// Nullish coalescing (??) - default value
const name = user.profile?.address?.city ?? "Unknown";
// Non-null assertion (!) - you're sure it's not null
// const c = user.profile!.address!.city!; // risky
// Optional method call
const result = user.profile?.address?.city?.toUpperCase();Type Aliases & Unions
Type Aliases
Type aliases create named references to any type, including unions, intersections, primitives, and generics. Unlike interfaces, aliases can't be merged or extended, but they're more flexible. Use aliases for unions, tuples, and utility types; use interfaces for object shapes.
// Basic alias
type ID = string | number;
type Point = { x: number; y: number };
// Generic alias
type Container<T> = { value: T };
// Function type alias
type Handler<T> = (event: T) => void;
// Usage
const id: ID = 42;
const p: Point = { x: 1, y: 2 };
const box: Container<string> = { value: "hi" };
const onClick: Handler<string> = (e) => console.log(e);Union Types
Union types (A | B) allow a value to be one of several types. TypeScript narrows the type inside conditional blocks using typeof, instanceof, or in checks. Note: (string | number)[] is different from string[] | number[] — the former is a mixed array, the latter is all-strings OR all-numbers.
// Union: value can be one of several types
type ID = string | number;
function display(id: ID) {
if (typeof id === "string") {
console.log(id.toUpperCase()); // narrowed to string
} else {
console.log(id.toFixed(2)); // narrowed to number
}
}
display("abc"); // ABC
display(42); // 42.00
// Union of arrays vs array of unions
type Mixed = (string | number)[];
type Either = string[] | number[];Intersection Types
Intersection types (A & B) combine all members of multiple types — the result must satisfy every type. Useful for mixins, composition, and merging utility types. Unlike union (OR), intersection is AND: the value must have all properties from all types.
// Intersection: combine multiple types into one
interface BusinessPartner {
name: string;
credit: number;
}
interface Identity {
id: number;
email: string;
}
type Employee = BusinessPartner & Identity;
const emp: Employee = {
name: "Alice",
credit: 1000,
id: 1,
email: "[email protected]",
};
// All properties required
// const bad: Employee = { name: "Bob" }; // Error: missing propsNullable Types
In strict mode, null and undefined are not assignable to other types — you must explicitly include them with unions (string | null). Optional parameters (param?) are implicitly T | undefined. Use ?? for safe defaults and ! to assert non-null (use sparingly).
// In strict mode, null/undefined are separate types
let name: string | null = null;
name = "Alice"; // OK
// Optional parameters are implicitly | undefined
function greet(name?: string) {
// name is string | undefined
console.log(name ?? "Guest");
}
// Return type can be null
function find(id: number): string | null {
return id === 1 ? "Alice" : null;
}
// Non-null assertion
const result = find(1)!.toUpperCase(); // "ALICE"Keyof & Typeof Operators
'keyof T' extracts the keys of type T as a string literal union. 'typeof x' extracts the type of a value (useful for inferring from objects). 'keyof typeof obj' combines both to get the keys of an existing object — common in Redux action types and type-safe property accessors.
interface User {
id: number;
name: string;
email: string;
}
// keyof: extract keys as a union
type UserKey = keyof User; // "id" | "name" | "email"
function getProp(obj: User, key: keyof User) {
return obj[key];
}
// typeof: extract type from a value
const config = { port: 3000, host: "localhost" };
type Config = typeof config; // { port: number; host: string }
// keyof typeof: keys of an object
type ConfigKey = keyof typeof config; // "port" | "host"Mapped Types
Mapped types iterate over keys to transform a type. Built-in utilities like Readonly, Partial, and Pick are mapped types. Use + and - modifiers to add/remove readonly or optional. Key remapping (TS 4.1+) renames keys using template literal types — powerful for generating getter/setter types.
// Map over keys to create a new type
type Readonly<T> = {
readonly [K in keyof T]: T[K];
};
type Optional<T> = {
[K in keyof T]?: T[K];
};
interface User { id: number; name: string; }
type ReadonlyUser = Readonly<User>; // all readonly
type OptionalUser = Optional<User>; // all optional
// Modifiers: +add, -remove
type Mutable<T> = { -readonly [K in keyof T]: T[K]; };
type Required<T> = { [K in keyof T]-?: T[K]; };
// Key remapping (TS 4.1+)
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};Functions
Function Types & Signatures
TypeScript adds type annotations to function parameters and return values. The return type can often be inferred, but explicit annotation is recommended for public APIs. Default parameters make arguments optional with a fallback value. Use void when a function doesn't return anything.
// Named function with types
function add(a: number, b: number): number {
return a + b;
}
// Arrow function with types
const multiply = (a: number, b: number): number => a * b;
// Function type alias
type MathOp = (a: number, b: number) => number;
const divide: MathOp = (a, b) => a / b;
// Void return (no return value)
function log(msg: string): void { console.log(msg); }
// Optional and default parameters
function greet(name: string, greeting: string = "Hi"): string {
return `${greeting}, ${name}!`;
}
greet("Alice"); // "Hi, Alice!"
greet("Bob", "Hello"); // "Hello, Bob!"Rest Parameters & Tuples
Rest parameters (...args) collect multiple arguments into an array. TypeScript types them as T[] or a tuple for fixed-length variadic functions. The spread operator (...) does the reverse — expands an array into individual arguments. Tuple rest types enable precise variadic signatures.
// Rest parameters (variadic)
function sum(...nums: number[]): number {
return nums.reduce((a, b) => a + b, 0);
}
console.log(sum(1, 2, 3, 4)); // 10
// Tuple rest (fixed prefix + variadic tail)
function pair(name: string, ...scores: number[]): void {
console.log(name, scores);
}
// Spread call
const nums = [1, 2, 3];
console.log(sum(...nums));
// Typed rest as tuple
function f(...args: [string, number, boolean]): void {
const [s, n, b] = args;
}Function Overloads
Function overloads provide multiple type signatures for the same function, enabling precise return types based on input. The implementation signature is hidden from callers. Overloads are resolved top-down — put more specific signatures first. Common in libraries like jQuery and lodash.
// Overload signatures (what callers see)
function parse(input: string): string[];
function parse(input: number): number[];
// Implementation signature (not visible to callers)
function parse(input: string | number): string[] | number[] {
if (typeof input === "string") {
return input.split(",");
}
return [input, input * 2];
}
const strs = parse("a,b,c"); // string[]
const nums = parse(42); // number[]
// Overloads with different param counts
function makeDate(timestamp: number): Date;
function makeDate(y: number, m: number, d: number): Date;
function makeDate(yOrTs: number, m?: number, d?: number): Date {
return m === undefined
? new Date(yOrTs)
: new Date(yOrTs, m - 1, d);
}this Type
TypeScript lets you declare the 'this' type as the first parameter. This ensures the function is called with the correct context — useful for methods passed as callbacks. Arrow functions capture 'this' lexically, avoiding the need for .bind(). Use 'noImplicitThis' to catch untyped 'this' errors.
interface Card {
suit: string;
rank: string;
isFaceUp(): boolean;
}
// Explicit 'this' parameter
function format(this: Card): string {
return `${this.rank} of ${this.suit}`;
}
const card: Card = {
suit: "Hearts",
rank: "A",
isFaceUp() { return true; },
format,
};
// 'this' in callbacks with bind
class Handler {
private count = 0;
increment = () => { this.count++; }; // arrow binds this
}Callbacks & Higher-Order Functions
TypeScript fully types higher-order functions (functions that take or return functions). Use generic type parameters (T, U) to preserve type relationships between input and output. Callback types are commonly defined as type aliases for reuse. Currying (returning functions) is fully type-safe.
// Function as parameter
type Callback<T> = (value: T, index: number) => void;
function forEach<T>(arr: T[], cb: Callback<T>): void {
for (let i = 0; i < arr.length; i++) {
cb(arr[i], i);
}
}
forEach(["a", "b"], (v, i) => console.log(i, v));
// Function returning function (curry)
function add(a: number): (b: number) => number {
return (b) => a + b;
}
const add5 = add(5);
console.log(add5(3)); // 8
// Generic map
function map<T, U>(arr: T[], fn: (x: T) => U): U[] {
return arr.map(fn);
}Parameter Destructuring
TypeScript supports destructuring in function parameters — annotate the destructured shape inline or via an interface. Extracting to an interface improves readability and reuse. Array/tuple destructuring works too. This pattern is common in React component props and API handlers.
// Destructured parameters with types
function createUser({ name, age, email }: {
name: string;
age: number;
email?: string;
}): void {
console.log(name, age, email);
}
createUser({ name: "Alice", age: 30 });
// Extract to interface for reuse
interface UserOpts {
name: string;
age: number;
email?: string;
}
function updateUser({ name, age }: UserOpts): void {}
// Array destructuring in params
function swap([a, b]: [number, number]): [number, number] {
return [b, a];
}Classes & OOP
Class & Constructor
TypeScript classes support parameter properties — prefixing constructor params with access modifiers (public/private/protected/readonly) auto-creates and assigns fields. This shorthand reduces boilerplate. Methods can have type annotations on return values. Fields default to public.
class Person {
// Parameter properties (shorthand)
constructor(
public name: string, // auto-creates this.name
private age: number, // private field
readonly id: number, // immutable
) {}
greet(): string {
return `Hi, I'm ${this.name}`;
}
}
const p = new Person("Alice", 30, 1);
console.log(p.name); // "Alice" (public)
// p.age; // Error: private
// p.id = 2; // Error: readonlyAccess Modifiers
Access modifiers: public (default, everywhere), private (class only), protected (class + subclasses), readonly (immutable). TypeScript's 'private' is compile-time only; ES '#' private fields are truly private at runtime. Use private for implementation details, protected for extension points.
class BankAccount {
public owner: string; // accessible everywhere
private balance: number; // class only
protected rate: number; // class + subclasses
readonly id: string; // immutable after init
#secret: string; // ES private (runtime)
constructor(owner: string) {
this.owner = owner;
this.balance = 0;
this.rate = 0.05;
this.id = crypto.randomUUID();
this.#secret = "hidden";
}
deposit(amount: number): void {
this.balance += amount;
}
}Inheritance & Abstract Classes
Abstract classes can't be instantiated directly — they define a base for subclasses. Abstract methods have no implementation in the base class; subclasses must implement them. Use 'extends' for inheritance and 'super()' to call the parent constructor. Abstract classes enable polymorphism — code can work with any Shape subclass.
abstract class Shape {
constructor(public color: string) {}
abstract area(): number; // must be implemented
describe(): string {
return `${this.color} shape, area ${this.area()}`;
}
}
class Circle extends Shape {
constructor(color: string, private r: number) {
super(color);
}
area(): number { return Math.PI * this.r ** 2; }
}
class Square extends Shape {
constructor(color: string, private side: number) {
super(color);
}
area(): number { return this.side ** 2; }
}
const c = new Circle("red", 5);
console.log(c.describe()); // "red shape, area 78.54..."
// new Shape("blue"); // Error: cannot instantiate abstractInterfaces & Implements
A class can implement multiple interfaces (separated by commas). The class must provide all interface members. Unlike extends (single inheritance), implements supports multiple contracts. This is TypeScript's way to achieve multiple-inheritance-like behavior. Use interfaces to define contracts, classes to implement them.
interface Printable {
toString(): string;
}
interface Comparable<T> {
compareTo(other: T): number;
}
class Money implements Printable, Comparable<Money> {
constructor(private amount: number) {}
toString(): string {
return `$${this.amount.toFixed(2)}`;
}
compareTo(other: Money): number {
return this.amount - other.amount;
}
}
const a = new Money(10);
const b = new Money(20);
console.log(a.toString()); // "$10.00"
console.log(a.compareTo(b)); // -10Getters & Setters
Getters and setters intercept property access for validation, computation, or side effects. Use a private backing field (convention: underscore prefix). Getters enable computed properties (like fahrenheit from celsius). Setters enable validation. Access them like regular properties — no parentheses.
class Temperature {
private _celsius: number = 0;
get celsius(): number {
return this._celsius;
}
set celsius(value: number) {
if (value < -273.15) throw new Error("Below absolute zero");
this._celsius = value;
}
get fahrenheit(): number {
return this._celsius * 9 / 5 + 32;
}
set fahrenheit(value: number) {
this.celsius = (value - 32) * 5 / 9;
}
}
const temp = new Temperature();
temp.celsius = 25;
console.log(temp.fahrenheit); // 77
// temp.celsius = -300; // throwsStatic Members & Singletons
Static members belong to the class, not instances — accessed via ClassName.member. Use static for constants, utility functions, and factory methods. A private constructor + static getInstance() implements the Singleton pattern. 'as const' makes static arrays readonly with literal types.
class Logger {
static instance: Logger;
private logs: string[] = [];
private constructor() {} // prevent direct new
static getInstance(): Logger {
if (!Logger.instance) {
Logger.instance = new Logger();
}
return Logger.instance;
}
static readonly LEVELS = ["INFO", "WARN", "ERROR"] as const;
log(msg: string): void {
this.logs.push(msg);
}
}
const logger = Logger.getInstance();
console.log(Logger.LEVELS); // ["INFO", "WARN", "ERROR"]
// new Logger(); // Error: private constructorGenerics
Generic Functions
Generics (<T>) let you write functions that work with any type while preserving type safety. The type parameter T is a placeholder filled in at call time — either explicitly (identity<number>) or inferred from arguments. Generics enable reusable, type-safe data structures and algorithms.
// Generic function: preserves type relationship
function identity<T>(value: T): T {
return value;
}
const n = identity<number>(42); // T = number, returns number
const s = identity("hi"); // T inferred as string
// Generic with multiple type params
function pair<A, B>(a: A, b: B): [A, B] {
return [a, b];
}
const p = pair("Alice", 30); // [string, number]
// Generic with array
function first<T>(arr: T[]): T | undefined {
return arr[0];
}Generic Classes
Generic classes (<T>) create type-safe containers. Each instance locks in a specific type — a Stack<number> only accepts numbers. This catches type errors at compile time without runtime overhead (generics are erased). Common in collections (Stack, Queue, Map) and reactive wrappers (Observable<T>).
class Stack<T> {
private items: T[] = [];
push(item: T): void {
this.items.push(item);
}
pop(): T | undefined {
return this.items.pop();
}
peek(): T | undefined {
return this.items[this.items.length - 1];
}
get size(): number {
return this.items.length;
}
}
const numStack = new Stack<number>();
numStack.push(1);
numStack.push(2);
console.log(numStack.pop()); // 2
const strStack = new Stack<string>();
strStack.push("hello");Generic Constraints
Constraints (T extends SomeType) restrict what types a generic can accept. 'T extends HasLength' ensures T has a 'length' property. 'K extends keyof T' (keyof constraint) ensures a key exists on an object, returning the correct value type. Constraints enable type-safe property access and method calls on generics.
// Constraint: T must have a 'length' property
interface HasLength {
length: number;
}
function logLength<T extends HasLength>(item: T): void {
console.log(item.length);
}
logLength("hello"); // 5 (string has length)
logLength([1, 2, 3]); // 3 (array has length)
// logLength(42); // Error: number has no length
// Constraint with keyof
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: "Alice", age: 30 };
const name = getProperty(user, "name"); // string
// getProperty(user, "email"); // Error: not a keyDefault Type Parameters
Default type parameters provide a fallback type when none is specified. Useful for APIs with a common case (e.g., ApiResponse defaults to string). Defaults can depend on earlier parameters. Combine with constraints (T extends X = DefaultType) for type-safe optional generics.
// Default type parameter
interface ApiResponse<T = string> {
data: T;
status: number;
}
const r1: ApiResponse = { data: "hello", status: 200 }; // T = string
const r2: ApiResponse<number> = { data: 42, status: 200 }; // T = number
// Multiple defaults
class Container<T = string, U = number> {
constructor(public a: T, public b: U) {}
}
const c1 = new Container("hi", 42); // Container<string, number>
const c2 = new Container<boolean>(true); // Container<boolean, number>
// Default with constraint
interface Box<T extends object = { id: number }> {
value: T;
}Generic Interfaces & Types
Generic interfaces and type aliases create reusable, type-safe contracts. Repository<T> abstracts data access with a consistent API. Result<T, E> is a discriminated union for error handling without exceptions. Default type parameters (E = Error) reduce boilerplate for common cases.
// Generic interface
interface Repository<T> {
findById(id: string): Promise<T>;
save(item: T): Promise<void>;
delete(id: string): Promise<void>;
}
// Implement with concrete type
class UserRepo implements Repository<User> {
async findById(id: string): Promise<User> { /* ... */ }
async save(user: User): Promise<void> { /* ... */ }
async delete(id: string): Promise<void> { /* ... */ }
}
// Generic type alias with conditional
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
const success: Result<number> = { ok: true, value: 42 };
const failure: Result<string> = { ok: false, error: "not found" };Conditional Types
Conditional types (T extends U ? X : Y) are type-level if-statements. 'infer R' extracts a type from within another type (e.g., a function's return type). Conditional types distribute over unions — ToArray<string | number> becomes string[] | number[]. Built-in utilities like Exclude, Extract, and NonNullable use this.
// Conditional type: if T extends U, use X, else Y
type IsString<T> = T extends string ? true : false;
type A = IsString<"hi">; // true
type B = IsString<42>; // false
// Extract return type
type AsyncReturnType<T> =
T extends (...args: any[]) => Promise<infer R> ? R : never;
async function fetchUser(): Promise<User> { /* ... */ }
type U = AsyncReturnType<typeof fetchUser>; // User
// Distributive conditional types
type ToArray<T> = T extends any ? T[] : never;
type R = ToArray<string | number>; // string[] | number[]
// Exclude / Extract built on conditionals
type NonNullable<T> = T extends null | undefined ? never : T;Advanced Types
Utility Types
TypeScript provides built-in utility types for common transformations: Partial (all optional), Pick (select keys), Omit (exclude keys), Record (key-value map), Required (remove optional), ReturnType (function return), Parameters (function params as tuple). These eliminate repetitive type definitions.
interface User {
id: number;
name: string;
email: string;
age: number;
}
// Partial: all optional (for updates)
type UserUpdate = Partial<User>;
const patch: UserUpdate = { name: "Bob" };
// Pick: select specific keys
type UserSummary = Pick<User, "id" | "name">;
// Omit: exclude specific keys
type CreateUser = Omit<User, "id">;
// Record: key-value map
type UserMap = Record<string, User>;
// Required: all required (remove ?)
type StrictUser = Required<Partial<User>>;
// ReturnType: function return type
type R = ReturnType<() => string>; // string
// Parameters: function parameter types as tuple
type P = Parameters<(a: number, b: string) => void>; // [number, string]Template Literal Types
Template literal types (TS 4.1+) build string types by interpolating other types. Combined with unions, they generate cartesian products of strings. Capitalize/Uppercase transform the case. Use them for type-safe event names, getter/setter generation, and API route typing. The 'as' clause in mapped types enables key renaming.
// Build string types from other types
type Vertical = "top" | "bottom";
type Horizontal = "left" | "right";
type Position = `${Vertical}-${Horizontal}`;
// "top-left" | "top-right" | "bottom-left" | "bottom-right"
// Uppercase, Lowercase, Capitalize, Uncapitalize
type Upper = Uppercase<"hello">; // "HELLO"
type Cap = Capitalize<"foo">; // "Foo"
// Getter names from keys
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; }
// Event listener types
type EventName = `on${Capitalize<"click" | "hover">}`;
// "onClick" | "onHover"infer Keyword
The 'infer' keyword declares a type variable within a conditional type's 'extends' clause, capturing a type for reuse. It's the foundation of utility types like ReturnType, Parameters, and Awaited. Use infer to extract types from complex structures (arrays, promises, functions) without manually decomposing them.
// Extract return type of a function
type MyReturnType<T> =
T extends (...args: any[]) => infer R ? R : never;
type R1 = MyReturnType<() => string>; // string
type R2 = MyReturnType<(x: number) => boolean>; // boolean
// Extract element type of an array
type ElementOf<T> = T extends (infer E)[] ? E : never;
type E = ElementOf<string[]>; // string
// Extract Promise value
type Unwrap<T> = T extends Promise<infer U> ? U : T;
type V = Unwrap<Promise<number>>; // number
// Extract first parameter
type FirstParam<T> =
T extends (first: infer F, ...rest: any[]) => any ? F : never;
type FP = FirstParam<(name: string, age: number) => void>; // stringDiscriminated Unions
Discriminated unions (tagged unions) use a shared literal field ('type', 'kind', 'tag') to distinguish variants. TypeScript narrows the type in each switch case, giving access to case-specific fields. The 'never' default enables exhaustiveness checking — if you add a new case, the compiler errors until you handle it. Essential for Redux reducers and state machines.
// Discriminated union: shared 'type' (or 'kind') field
type Action =
| { type: "ADD_TODO"; text: string }
| { type: "DELETE_TODO"; id: number }
| { type: "TOGGLE_TODO"; id: number };
function reducer(state: Todo[], action: Action): Todo[] {
switch (action.type) {
case "ADD_TODO":
return [...state, { id: Date.now(), text: action.text }];
case "DELETE_TODO":
return state.filter(t => t.id !== action.id);
case "TOGGLE_TODO":
return state.map(t =>
t.id === action.id ? { ...t, done: !t.done } : t
);
default:
const _: never = action; // exhaustiveness check
return state;
}
}Type Guards: typeof & instanceof
Type guards narrow types at runtime. 'typeof' works for primitives (string, number, boolean, symbol, bigint, undefined, function, object). 'instanceof' checks class/constructor prototypes. Array.isArray() narrows to a typed array. These are built-in guards — no custom code needed. TypeScript tracks the narrowed type in each branch.
// typeof: narrow primitives
function process(value: string | number) {
if (typeof value === "string") {
return value.toUpperCase(); // string
}
return value.toFixed(2); // number
}
// instanceof: narrow class instances
class Dog { bark() {} }
class Cat { meow() {} }
function speak(pet: Dog | Cat) {
if (pet instanceof Dog) {
pet.bark(); // Dog
} else {
pet.meow(); // Cat
}
}
// Array.isArray
function flatten(arr: (number | number[])[]) {
return arr.flatMap(x =>
Array.isArray(x) ? x : [x]
);
}Custom Type Guards & in Operator
The 'in' operator checks if a property exists on an object, narrowing to the type that has it. Type predicates (x is T) are custom guard functions that return boolean but also narrow the type. Use 'unknown' as the input type for safe parsing of external data (JSON.parse, API responses). Predicates enable reusable, composable type checks.
// 'in' operator: check for property existence
interface Fish { swim(): void; }
interface Bird { fly(): void; }
function move(animal: Fish | Bird) {
if ("swim" in animal) {
animal.swim(); // Fish
} else {
animal.fly(); // Bird
}
}
// Type predicate: custom guard function
function isString(x: unknown): x is string {
return typeof x === "string";
}
function isUser(x: any): x is { name: string; age: number } {
return x && typeof x.name === "string" && typeof x.age === "number";
}
const val: unknown = "hello";
if (isString(val)) {
console.log(val.toUpperCase()); // narrowed to string
}Data Structures
Arrays & ReadonlyArray
TypeScript arrays are typed — methods like map, filter, and reduce preserve element types. Use readonly T[] or ReadonlyArray<T> for immutability. Tuples have fixed length and typed positions. Array destructuring and spread are fully type-safe. The type system catches index-out-of-bounds and wrong-type assignments.
// Typed arrays
let nums: number[] = [1, 2, 3];
let strs: Array<string> = ["a", "b", "c"];
// ReadonlyArray (immutable)
const frozen: readonly number[] = [1, 2, 3];
// frozen.push(4); // Error: readonly
// Tuple (fixed length)
let pair: [string, number] = ["Alice", 30];
// Array methods are typed
const doubled = nums.map(n => n * 2); // number[]
const evens = nums.filter(n => n % 2 === 0); // number[]
const sum = nums.reduce((a, b) => a + b, 0); // number
// Spread and destructuring
const [first, ...rest] = nums;
const combined = [...nums, ...[4, 5]];Objects & Records
Record<K, V> creates a typed map with keys of type K and values of type V. Partial<T> makes all properties optional — ideal for update/patch operations. Object.entries/keys/values return typed arrays. Use Pick and Omit to derive focused types from existing ones, keeping types DRY.
// Object type
const user: { name: string; age: number } = { name: "Alice", age: 30 };
// Record: typed key-value map
const scores: Record<string, number> = {
math: 90,
science: 85,
};
// Partial: all optional (for patches)
const patch: Partial<typeof user> = { age: 31 };
// Pick / Omit
type Summary = Pick<typeof user, "name">;
type WithoutAge = Omit<typeof user, "age">;
// Object.entries / keys / values typed
const entries = Object.entries(scores); // [string, number][]
const keys = Object.keys(scores); // string[]
const values = Object.values(scores); // number[]Maps & Sets
Map and Set are ES6 collections with full TypeScript support. Map keys can be any type (unlike objects, which coerce keys to strings). Set stores unique values. WeakMap/WeakSet allow garbage collection of keys — useful for metadata attached to DOM elements or objects without preventing GC.
// Map: keyed collection (any key type)
const map = new Map<string, User>();
map.set("alice", { name: "Alice", age: 30 });
const user = map.get("alice"); // User | undefined
// Set: unique values
const unique = new Set<number>([1, 2, 2, 3]);
console.log(unique.size); // 3
console.log(unique.has(2)); // true
// Iteration (typed)
for (const [key, value] of map) {
console.log(key, value.name);
}
for (const num of unique) {
console.log(num);
}
// WeakMap / WeakSet (keys must be objects, GC-friendly)
const weak = new WeakMap<object, string>();Tuples & Labeled Tuples
Tuples are fixed-length arrays with typed positions. Labeled tuples (TS 4.0+) add names for readability — useful for return values and CSV-like data. Tuples enable multiple return values without creating an interface. Use 'readonly' to prevent mutation. Tuples differ from arrays: [string, number] is NOT (string | number)[].
// Basic tuple
let point: [number, number] = [10, 20];
// Labeled tuple (TS 4.0+)
let user: [id: number, name: string, active: boolean] = [1, "Alice", true];
// Destructuring with labels
const [id, name, active] = user;
// Tuple in function returns
function divmod(a: number, b: number): [quotient: number, remainder: number] {
return [Math.floor(a / b), a % b];
}
const [q, r] = divmod(17, 5);
console.log(q, r); // 3 2
// Readonly tuple
const fixed: readonly [string, number] = ["a", 1];
// fixed.push(2); // ErrorEnums & Const Enums
Enums create named constants. String enums are recommended (readable in output, no reverse mapping issues). Const enums are erased at compile time (zero runtime cost). For simple cases, union types ('a' | 'b') are often better — no runtime code, better tree-shaking. Use enums for grouped, documented constants.
// Numeric enum
enum Direction { North = 0, South = 1, East = 2, West = 3 }
// String enum (recommended)
enum HttpStatus {
OK = "200 OK",
NotFound = "404 Not Found",
ServerError = "500 Internal Server Error",
}
// Const enum (inlined at compile time)
const enum Color { Red, Green, Blue }
let c = Color.Red; // compiles to: let c = 0;
// Union type alternative (no runtime code)
type Status = "pending" | "success" | "error";
const s: Status = "pending";
// Exhaustive switch
function handle(s: Status) {
switch (s) {
case "pending": return "loading";
case "success": return "done";
case "error": return "failed";
}
}Immutable Data
TypeScript offers multiple immutability tools: 'readonly' for properties, ReadonlyArray for arrays, Readonly<T> utility, and 'as const' for deep readonly with literal types. Immutable data prevents accidental mutations and enables change detection (React, Redux). Use spread (...) for immutable updates — creates a new object with modified fields.
// Readonly modifier
interface User {
readonly id: number;
name: string;
}
// ReadonlyArray
const nums: ReadonlyArray<number> = [1, 2, 3];
// nums[0] = 9; // Error
// Readonly utility type
type FrozenUser = Readonly<User>;
// as const: deep readonly with literal types
const config = {
endpoint: "/api",
methods: ["GET", "POST"],
} as const;
// config.endpoint: "/api" (literal, not string)
// config.methods: readonly ["GET", "POST"]
// Immutable update pattern
const user = { name: "Alice", age: 30 };
const updated = { ...user, age: 31 }; // new objectModules & Namespaces
ES Modules: Import & Export
TypeScript uses ES module syntax (import/export). Named exports are explicit; default export is the single 'main' export. Use 'import * as' for namespace imports. Module resolution follows Node.js conventions (node_modules, extensions). Configure 'module' and 'moduleResolution' in tsconfig.json.
// math.ts - exporting
export function add(a: number, b: number): number {
return a + b;
}
export const PI = 3.14159;
export default function multiply(a: number, b: number): number {
return a * b;
}
// main.ts - importing
import multiply, { add, PI } from "./math";
import * as math from "./math"; // namespace import
console.log(add(1, 2)); // 3
console.log(multiply(3, 4)); // 12 (default)
console.log(math.PI); // 3.14159Type-Only Imports
'import type' imports only types (erased at compile time, no runtime code). This avoids circular dependencies and unnecessary runtime imports. TS 4.5+ allows inline 'type' modifiers in mixed imports. Use type-only imports for interfaces, type aliases, and enums (if const) to reduce bundle size.
// Type-only import (erased at runtime)
import type { User, Config } from "./types";
// Mixed import (TS 4.5+)
import { render, type Component } from "./ui";
// Re-export types
export type { User } from "./types";
// Import type for interfaces and types
interface UserService {
get(id: string): User; // User from type-only import
}
// 'import type' ensures no runtime dependency
// Useful when the module has side effects you want to avoidDynamic Imports
Dynamic imports (import()) load modules on demand, returning a Promise. This enables code splitting and lazy loading — critical for performance in web apps. TypeScript infers the module type automatically. Use for optional features, large libraries, and route-based code splitting (React.lazy, Next.js dynamic).
// Dynamic import returns a Promise
async function loadModule() {
const module = await import("./heavy-module");
module.doWork();
}
// Conditional loading
if (featureEnabled) {
const { Feature } = await import("./feature");
new Feature().init();
}
// Type the dynamic import
type HeavyModule = typeof import("./heavy-module");
// With error handling
try {
const lib = await import("./optional-lib");
lib.run();
} catch (e) {
console.log("Library not available");
}Declaration Files & Module Augmentation
Declaration files (.d.ts) describe types for JS modules, CSS/PNG imports, and global variables. Module augmentation extends existing module types — useful for adding properties to Express Request, Express Response, or third-party types. This is how middleware like passport adds req.user typing.
// global.d.ts - declare ambient modules
declare module "*.css" {
const content: string;
export default content;
}
declare module "*.png" {
const src: string;
export default src;
}
// Module augmentation - extend existing types
import express from "express";
declare module "express" {
interface Request {
user?: { id: string; name: string };
}
}
// Now req.user is typed
app.get("/", (req, res) => {
console.log(req.user?.name);
});Namespaces (Legacy)
Namespaces are TypeScript's pre-ES6 module system. They group related code under a named object. For new projects, prefer ES modules (import/export) — they're standardized, tree-shakeable, and work with bundlers. Namespaces remain useful in .d.ts declaration files for global type declarations and legacy code.
// Namespace: pre-ES6 way to organize code
namespace Validation {
export interface StringValidator {
isValid(s: string): boolean;
}
const lettersRegexp = /^[A-Za-z]+$/;
export class LettersOnlyValidator implements StringValidator {
isValid(s: string): boolean {
return lettersRegexp.test(s);
}
}
}
// Usage
const validator = new Validation.LettersOnlyValidator();
console.log(validator.isValid("Hello")); // true
// Prefer ES modules over namespaces for new code
// Namespaces are useful in .d.ts files for global declarationstsconfig Module Settings
tsconfig module settings control how TypeScript handles imports. 'moduleResolution: node' uses Node.js resolution (node_modules lookup). 'esModuleInterop' enables default imports from CommonJS. 'paths' creates import aliases (@/components) for cleaner imports. 'resolveJsonModule' allows importing .json files with inferred types.
{
"compilerOptions": {
"module": "ESNext", // ES module output
"moduleResolution": "node", // Node-style resolution
"esModuleInterop": true, // allow default imports from CJS
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true, // import .json files
"isolatedModules": true, // each file is independent
"baseUrl": "./src", // base for non-relative imports
"paths": {
"@/*": ["./*"], // path alias
"@components/*": ["./components/*"]
}
}
}
// With paths config, you can import:
// import { Button } from "@/components/Button";
// instead of relative paths like "../../components/Button"Async & Promises
Promise Types
Promise<T> is the core async type — T is the resolved value type. TypeScript infers types through .then() chains. Use 'new Promise()' for wrapping callback-based APIs. Always type the resolve/reject values. Prefer async/await over raw .then() chains for readability and error handling.
// Promise<T> represents an async value
function fetchUser(id: number): Promise<User> {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id === 1) {
resolve({ id: 1, name: "Alice" });
} else {
reject(new Error("User not found"));
}
}, 100);
});
}
// Consuming a Promise
fetchUser(1)
.then(user => console.log(user.name))
.catch(err => console.error(err))
.finally(() => console.log("done"));
// Promise typing: then callbacks are typed
fetchUser(1).then(user => {
// user is User, not any
console.log(user.name); // string
});async/await
async/await is syntactic sugar over Promises — 'await' pauses until the Promise resolves. async functions always return a Promise. Use Promise.all() for parallel execution (much faster than sequential awaits). Top-level await works in ES modules with ES2022+. TypeScript checks that awaited values are Promises.
// async function returns Promise<T>
async function getUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json() as Promise<User>;
}
// Top-level await (ES2022, TS 4.7+)
// const user = await getUser(1);
// Sequential vs parallel
async function loadAll() {
// Sequential (slow)
const a = await getUser(1);
const b = await getUser(2);
// Parallel (fast)
const [x, y] = await Promise.all([getUser(1), getUser(2)]);
return { a, b, x, y };
}Error Handling in Async
Use try/catch with async/await for error handling — it's cleaner than .catch(). TypeScript doesn't support typed throws (all errors are unknown in catch), so narrow with instanceof. For predictable errors, consider the Result type pattern (ok/error union) instead of exceptions — it makes error handling explicit in the type signature.
// Try/catch with async/await
async function riskyOperation(): Promise<string> {
try {
const data = await fetch("/api/data");
if (!data.ok) throw new Error(`HTTP ${data.status}`);
return await data.text();
} catch (error) {
if (error instanceof Error) {
console.error(error.message);
}
return "fallback";
} finally {
console.log("cleanup");
}
}
// Typed errors (TypeScript doesn't have typed throws)
class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
// Result type as alternative to exceptions
type Result<T> =
| { ok: true; value: T }
| { ok: false; error: string };
async function safeFetch(url: string): Promise<Result<string>> {
try {
const res = await fetch(url);
return { ok: true, value: await res.text() };
} catch (e) {
return { ok: false, error: String(e) };
}
}Promise Combinators
Promise combinators orchestrate multiple async operations: all() (parallel, fail-fast), allSettled() (parallel, wait for all), race() (first to settle), any() (first to succeed). Use all() for dependent data loading, allSettled() when you want partial results, race() for timeouts, any() for redundant fetches.
// Promise.all: wait for all (rejects if any rejects)
const [users, posts] = await Promise.all([
fetchUsers(),
fetchPosts(),
]);
// Promise.allSettled: wait for all (never rejects)
const results = await Promise.allSettled([
fetch("/api/a"),
fetch("/api/b"),
]);
results.forEach(r => {
if (r.status === "fulfilled") console.log(r.value);
else console.log(r.reason);
});
// Promise.race: first to settle (resolve or reject)
const fastest = await Promise.race([
fetch("/api/fast"),
fetch("/api/slow"),
]);
// Promise.any: first to resolve (ignores rejections)
const first = await Promise.any([
fetch("/api/primary"),
fetch("/api/fallback"),
]);Event Loop & Microtasks
JavaScript's event loop processes microtasks (Promise callbacks, queueMicrotask) before macrotasks (setTimeout, setInterval). This is why Promises resolve before timeouts. Async iteration (for await...of) consumes async iterables — useful for streams. Async generators (async function*) produce async iterables, enabling lazy async sequences.
// Microtasks (Promises) run before macrotasks (setTimeout)
console.log("1: sync");
setTimeout(() => console.log("4: macrotask"), 0);
Promise.resolve().then(() => console.log("3: microtask"));
console.log("2: sync");
// Output: 1, 2, 3, 4
// queueMicrotask for custom microtasks
queueMicrotask(() => console.log("microtask"));
// Async iteration (for await)
async function processStream(stream: AsyncIterable<Buffer>) {
for await (const chunk of stream) {
console.log(chunk.length);
}
}
// Async generators
async function* generate() {
for (let i = 0; i < 3; i++) {
await new Promise(r => setTimeout(r, 100));
yield i;
}
}Concurrency Patterns
These patterns are fully type-safe in TypeScript. Debounce delays execution until calls stop for N ms (search input). Throttle limits to one call per N ms (scroll handlers). Semaphore/mapLimit controls concurrency — useful for rate-limited APIs. Parameters<T> and ReturnType<T> preserve function signatures in wrappers.
// Debounce with TypeScript
function debounce<T extends (...args: any[]) => void>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timer: ReturnType<typeof setTimeout>;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
// Throttle
function throttle<T extends (...args: any[]) => void>(
fn: T,
limit: number
): (...args: Parameters<T>) => void {
let inThrottle = false;
return (...args) => {
if (!inThrottle) {
fn(...args);
inThrottle = true;
setTimeout(() => (inThrottle = false), limit);
}
};
}
// Semaphore (limit concurrency)
async function mapLimit<T, U>(
items: T[],
limit: number,
fn: (item: T) => Promise<U>
): Promise<U[]> {
const results: U[] = [];
const executing: Promise<void>[] = [];
for (const item of items) {
const p = fn(item).then(r => results.push(r));
executing.push(p);
if (executing.length >= limit) {
await Promise.race(executing);
executing.splice(executing.findIndex(e => e === p), 1);
}
}
await Promise.all(executing);
return results;
}Error Handling & Testing
Try/Catch with Unknown
Since TypeScript 4.4 (useUnknownInCatchVariables), caught errors are 'unknown' — you must narrow them before use. This prevents accessing properties that don't exist. Use instanceof to check for specific error types, or String() as a fallback. Create a getErrorMessage() helper for consistent error extraction.
// In strict mode, catch is 'unknown' (not 'any')
try {
JSON.parse("invalid");
} catch (error: unknown) {
// Must narrow before using
if (error instanceof SyntaxError) {
console.error("JSON error:", error.message);
} else if (error instanceof Error) {
console.error(error.message);
} else {
console.error("Unknown error:", error);
}
}
// Helper function
function getErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
return String(error);
}Custom Error Classes
Custom error classes add structured data (code, statusCode, field) to errors. Always call super(message) and set the prototype (Object.setPrototypeOf) to fix the TypeScript/ES5 prototype chain issue. Use instanceof to distinguish error types in catch blocks. This pattern is essential for Express middleware and API error handlers.
class AppError extends Error {
constructor(
message: string,
public code: string,
public statusCode: number = 500
) {
super(message);
this.name = "AppError";
// Fix prototype chain (TS quirk)
Object.setPrototypeOf(this, AppError.prototype);
}
}
class ValidationError extends AppError {
constructor(message: string, public field: string) {
super(message, "VALIDATION_ERROR", 400);
this.name = "ValidationError";
}
}
// Usage
function createUser(input: unknown) {
if (typeof input !== "object" || input === null) {
throw new ValidationError("Invalid input", "body");
}
}
try {
createUser("bad");
} catch (e) {
if (e instanceof ValidationError) {
console.log(e.field, e.statusCode); // "body" 400
}
}Result Type Pattern
The Result type (from Rust) makes errors explicit in the type signature — callers must handle both success and failure. Unlike exceptions, the compiler enforces error handling. Use this for expected failures (validation, not-found) where exceptions would be overkill. Reserve exceptions for truly unexpected errors (bugs, system failures).
// Result type: explicit error handling without exceptions
type Result<T, E = string> =
| { ok: true; value: T }
| { ok: false; error: E };
function divide(a: number, b: number): Result<number> {
if (b === 0) {
return { ok: false, error: "Division by zero" };
}
return { ok: true, value: a / b };
}
// Usage: forced to handle both cases
const result = divide(10, 0);
if (result.ok) {
console.log(result.value); // number
} else {
console.error(result.error); // string
}
// Utility helpers
function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}Assertion Functions
Assertion functions (asserts X) throw if a condition fails AND narrow the type afterward. 'asserts value is string' tells TypeScript that after the call, value is string. This is cleaner than repeated if-checks. Use for runtime validation at boundaries (API input, config). Combine with Zod or io-ts for schema validation.
// Assertion function: throws if condition is false
function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
}
}
// Assert value is a specific type
function assertString(value: unknown): asserts value is string {
if (typeof value !== "string") {
throw new Error(`Expected string, got ${typeof value}`);
}
}
// Usage: narrows type after assertion
const input: unknown = "hello";
assertString(input);
console.log(input.toUpperCase()); // input is now string
// Assert non-null
function assertDefined<T>(value: T | undefined): asserts value is T {
if (value === undefined) throw new Error("undefined");
}Type-Safe JSON Parsing
JSON.parse returns 'any' — unsafe. Wrap it with a type guard to validate the shape at runtime and narrow the type. For complex schemas, use Zod, io-ts, or yup — they generate both runtime validators and TypeScript types from a single schema definition. This is critical for API responses and user input.
// Safe JSON parse with type guard
function safeParse<T>(json: string, guard: (x: unknown) => x is T): T | null {
try {
const parsed: unknown = JSON.parse(json);
if (guard(parsed)) return parsed;
return null;
} catch {
return null;
}
}
// Type guard for User
interface User { id: number; name: string; }
function isUser(x: unknown): x is User {
return typeof x === "object" && x !== null
&& typeof (x as User).id === "number"
&& typeof (x as User).name === "string";
}
const data = safeParse('{"id":1,"name":"Alice"}', isUser);
if (data) {
console.log(data.name); // User
}
// Using Zod for runtime validation
// import { z } from "zod";
// const UserSchema = z.object({ id: z.number(), name: z.string() });
// const user = UserSchema.parse(JSON.parse(json));Exhaustiveness Checking
Exhaustiveness checking ensures you handle all cases of a union. Assign the default case to 'never' — if you add a new variant to the union, TypeScript errors because the new type isn't assignable to 'never'. This catches missing cases at compile time. Essential for discriminated unions, Redux reducers, and state machines.
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; size: number }
| { kind: "rectangle"; w: number; h: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.size ** 2;
case "rectangle":
return shape.w * shape.h;
default:
// If a new shape is added, this errors
const _exhaustive: never = shape;
return _exhaustive;
}
}
// Adding a new variant:
// | { kind: "triangle"; base: number; height: number }
// Now the default case errors: Type 'triangle' is not assignable to 'never'Decorators & Metadata
Class Decorators
Class decorators receive the constructor function and can return a modified class. Decorator factories (returning a function) accept arguments. Decorators are an experimental feature — enable 'experimentalDecorators' in tsconfig. Used heavily in NestJS, TypeORM, and Angular for dependency injection and metadata.
// Class decorator: receives the constructor
function Logged<T extends new (...args: any[]) => any>(target: T): T {
return class extends target {
constructor(...args: any[]) {
super(...args);
console.log(`Created ${target.name}`);
}
};
}
@Logged
class Service {
constructor(public name: string) {}
}
const s = new Service("Auth");
// Logs: "Created Service"
// Decorator factory (with arguments)
function Prefix(prefix: string) {
return function <T extends new (...args: any[]) => any>(target: T): T {
return class extends target {
message = prefix + " " + (this as any).name;
};
};
}Method & Property Decorators
Method decorators receive (target, propertyKey, descriptor) and can wrap the original method — useful for logging, caching, and access control. Property decorators receive (target, key) and are often used to register metadata. The descriptor.value is the original function; wrap it to add behavior. Common in NestJS (@Get, @Post) and TypeORM (@Column).
// Method decorator: (target, key, descriptor)
function Log(
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const original = descriptor.value;
descriptor.value = function (...args: any[]) {
console.log(`Calling ${propertyKey}(${args})`);
return original.apply(this, args);
};
}
class Calculator {
@Log
add(a: number, b: number): number {
return a + b;
}
}
new Calculator().add(2, 3);
// Logs: "Calling add(2,3)"
// Property decorator: (target, key)
function Required(target: any, key: string) {
// Store metadata for validation
console.log(`Required: ${key}`);
}Parameter Decorators & Metadata
Parameter decorators receive (target, key, index) and are used for dependency injection (NestJS, Angular). The 'reflect-metadata' polyfill enables runtime type metadata — decorators can access parameter types via Reflect.getMetadata('design:paramtypes'). This is how DI containers know what to inject. Enable with 'emitDecoratorMetadata: true' in tsconfig.
import "reflect-metadata";
// Parameter decorator
function Inject(target: any, key: string, index: number) {
console.log(`Inject param ${index} of ${key}`);
}
class Service {
constructor(@Inject private dep: any) {}
}
// Store and retrieve metadata
const METADATA_KEY = "design:type";
class Example {
greet(name: string): void {}
}
// reflect-metadata provides:
// - design:type (property type)
// - design:paramtypes (method parameter types)
// - design:returntype (method return type)
const types = Reflect.getMetadata("design:paramtypes", Example.prototype, "greet");
// types: [String]Accessor Decorators
Accessor decorators apply to getters/setters. The descriptor has get/set properties you can wrap. Use them for validation, logging, or changing enumerability. The validation pattern (MaxLength, Min, Max) wraps the setter to enforce constraints at runtime. This is how class-validator (NestJS) works for DTO validation.
// Accessor decorator (getter/setter)
function Enumerable(value: boolean) {
return function (
target: any,
key: string,
descriptor: PropertyDescriptor
) {
descriptor.enumerable = value;
};
}
class Person {
private _name: string = "";
@Enumerable(true)
get name(): string {
return this._name;
}
set name(value: string) {
this._name = value;
}
}
const p = new Person();
p.name = "Alice";
console.log(Object.keys(p)); // ["name"] (enumerable)
// Validation decorator
function MaxLength(len: number) {
return function (target: any, key: string) {
let value: string;
Object.defineProperty(target, key, {
get() { return value; },
set(v: string) {
if (v.length > len) throw new Error("Too long");
value = v;
},
});
};
}Modern Decorators (TC39 Stage 3)
TypeScript 5.0 supports the TC39 Stage 3 decorator proposal — a standardized API replacing experimentalDecorators. The new API uses a context object (ClassMethodDecoratorContext) instead of (target, key, descriptor). It's cleaner, type-safe, and will eventually be in the JS standard. Use this for new projects; experimental decorators remain for NestJS/Angular compatibility.
// TC39 Stage 3 decorators (TS 5.0+, no experimentalDecorators needed)
// Uses a different API: context object
function log<This, Args extends any[], Return>(
target: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
) {
return function (this: This, ...args: Args): Return {
console.log(`Calling ${String(context.name)}`);
return target.call(this, ...args);
};
}
class Service {
@log
greet(name: string): string {
return `Hello, ${name}`;
}
}
// Class method decorator context provides:
// - name: property name
// - kind: "method" | "field" | "class"
// - access: { get(), set(value) } for fields
// - addInitializer(): run code after constructionPractical Decorator: Memoize
This memoize decorator caches method results based on arguments — dramatic speedups for expensive pure functions like fibonacci. The cache is per-instance (use a WeakMap for shared cache). Decorators shine for cross-cutting concerns: logging, caching, validation, access control, retry logic. They keep business logic clean by separating infrastructure concerns.
// Memoize: cache method results
function Memoize<This, Args extends any[], Return>(
target: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext
) {
const cache = new Map<string, Return>();
return function (this: This, ...args: Args): Return {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key)!;
const result = target.call(this, ...args);
cache.set(key, result);
return result;
};
}
class MathService {
@Memoize
fibonacci(n: number): number {
if (n < 2) return n;
return this.fibonacci(n - 1) + this.fibonacci(n - 2);
}
}
const svc = new MathService();
console.time("first");
console.log(svc.fibonacci(40)); // slow
console.timeEnd("first");
console.time("second");
console.log(svc.fibonacci(40)); // fast (cached)
console.timeEnd("second");Utility Types
Partial, Required & Readonly
Partial<T> makes all properties optional — perfect for update/patch operations where only some fields change. Required<T> is the inverse. Readonly<T> makes all properties immutable at compile time. These are the most commonly used utility types and eliminate the need to maintain parallel optional/readonly interfaces manually.
interface User {
id: number;
name: string;
email: string;
}
// Partial<T>: all properties become optional
type UserUpdate = Partial<User>;
// { id?: number; name?: string; email?: string }
function updateUser(id: number, changes: UserUpdate) {
// Only provided fields are updated
Object.assign(users[id], changes);
}
updateUser(1, { name: "Alice" }); // OK — only name
// Required<T>: all properties become required (inverse of Partial)
type StrictUser = Required<Partial<User>>; // back to all required
// Readonly<T>: all properties become readonly
type FrozenUser = Readonly<User>;
const frozen: FrozenUser = { id: 1, name: "Alice", email: "[email protected]" };
// frozen.name = "Bob"; // Error: readonly
// Practical: immutable config objects
const config: Readonly<Config> = { /* ... */ };
// config.port = 8081; // Error — prevents accidental mutationPick, Omit & Record
Pick<T, K> extracts a subset of properties; Omit<T, K> removes properties — both create derived types without duplication. Record<K, V> creates a map/dictionary type with specific keys. These are essential for DTOs (data transfer objects): derive a CreateUser type from User by omitting auto-generated fields like id and createdAt. This keeps types DRY and in sync.
interface User {
id: number;
name: string;
email: string;
role: string;
createdAt: Date;
}
// Pick<T, Keys>: select specific properties
type UserSummary = Pick<User, "id" | "name">;
// { id: number; name: string }
// Omit<T, Keys>: remove specific properties
type UserInput = Omit<User, "id" | "createdAt">;
// { name: string; email: string; role: string }
// Record<Keys, Value>: object with specific keys and value type
type UserRole = "admin" | "user" | "guest";
type Permissions = Record<UserRole, string[]>;
const perms: Permissions = {
admin: ["read", "write", "delete"],
user: ["read", "write"],
guest: ["read"],
};
// Combining: create a DTO from a full entity
type UserDTO = Pick<User, "id" | "name" | "email">;
type CreateUserDTO = Omit<User, "id" | "createdAt">;ReturnType, Parameters & Awaited
ReturnType and Parameters extract types from existing functions — invaluable when wrapping or calling functions whose signatures you don't want to duplicate. Awaited<T> unwraps nested Promises (Promise<Promise<T>> becomes T), essential for async function return types. InstanceType gets the instance type from a class constructor. These enable type-safe function composition and higher-order utilities.
function fetchUser(id: number): Promise<{ name: string; age: number }> {
return Promise.resolve({ name: "Alice", age: 30 });
}
// ReturnType<T>: the return type of a function
type FetchResult = ReturnType<typeof fetchUser>;
// Promise<{ name: string; age: number }>
// Awaited<T>: unwrap a Promise to its inner type
type User = Awaited<ReturnType<typeof fetchUser>>;
// { name: string; age: number }
// Parameters<T>: tuple of parameter types
type FetchParams = Parameters<typeof fetchUser>;
// [id: number]
// First parameter type
type FirstParam = Parameters<typeof fetchUser>[0]; // number
// ConstructorParameters<T>: parameters of a class constructor
class Point {
constructor(public x: number, public y: number) {}
}
type PointArgs = ConstructorParameters<typeof Point>; // [x: number, y: number]
// InstanceType<T>: the instance type of a constructor
type PointInstance = InstanceType<typeof Point>; // PointExclude, Extract & NonNullable
Exclude<T, U> removes types from a union; Extract<T, U> keeps only matching types — both operate on union members. NonNullable<T> strips null and undefined. These are building blocks: Omit is defined as Pick<T, Exclude<keyof T, K>>. Use Exclude/Extract to filter union types dynamically, e.g., separating error types from success types in a result union.
type Role = "admin" | "user" | "guest" | "superadmin";
// Exclude<T, U>: remove types from a union
type NonAdmin = Exclude<Role, "admin" | "superadmin">;
// "user" | "guest"
// Extract<T, U>: keep only matching types from a union
type AdminRoles = Extract<Role, "admin" | "superadmin">;
// "admin" | "superadmin"
// NonNullable<T>: remove null and undefined
type MaybeUser = User | null | undefined;
type DefiniteUser = NonNullable<MaybeUser>; // User
// Practical: filter union types
type EventMap = {
click: MouseEvent;
keydown: KeyboardEvent;
scroll: UIEvent;
};
type EventName = keyof EventMap; // "click" | "keydown" | "scroll"
// Omit<T, K> is actually built from Pick and Exclude:
// type Omit<T, K> = Pick<T, Exclude<keyof T, K>>;Custom Utility Types
Custom utility types compose built-in ones for specific needs. Optional<T, K> makes only certain fields optional (more targeted than Partial). DeepPartial/DeepReadonly recursively apply to nested objects — useful for config and state trees. The -readonly modifier in Mutable removes readonly. These patterns show how mapped types and conditional types combine for powerful type-level programming.
// Make specific properties optional
type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
interface User {
id: number;
name: string;
email: string;
}
type UserWithOptionalEmail = Optional<User, "email">;
// { id: number; name: string; email?: string }
// Make specific properties required
type Require<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>;
// Deep partial (recursively make all properties optional)
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};
// Deep readonly
type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
};
// Mutable (remove readonly from all properties)
type Mutable<T> = {
-readonly [P in keyof T]: T[P];
};
// Non-never keys (filter out never-valued properties)
type NonNeverKeys<T> = {
[K in keyof T]: T[K] extends never ? never : K;
}[keyof T];Conditional Types
Basic Conditional Types (T extends U ? X : Y)
Conditional types (T extends U ? X : Y) select a type based on a type-level condition — like a ternary for types. They're the foundation of TypeScript's type-level programming. When T is a union, the condition distributes over each member (distributive conditional types). The infer keyword extracts types from within a pattern, like pulling the element type out of an array or the resolve type out of a Promise.
// Conditional types choose a type based on a condition
// Syntax: T extends U ? X : Y
// Is T an array? Return its element type, else never
type ElementOf<T> = T extends (infer E)[] ? E : never;
type A = ElementOf<string[]>; // string
type B = ElementOf<number[]>; // number
type C = ElementOf<string>; // never (not an array)
// Is T a Promise? Unwrap it, else keep T
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type D = UnwrapPromise<Promise<number>>; // number
type E = UnwrapPromise<string>; // string (not a promise)
// Check if a type is a function
type IsFunction<T> = T extends (...args: any[]) => any ? true : false;
type F = IsFunction<() => void>; // true
type G = IsFunction<number>; // false
// Conditional types are evaluated lazily and distribute over unionsinfer Keyword (Type Extraction)
The infer keyword declares a type variable within a conditional type's extends clause, capturing whatever type matches that position. It's how ReturnType, Parameters, and Awaited are implemented. infer can be used recursively (Unwrap<Promise<Promise<T>>>) to fully unwrap nested types. It's the primary tool for extracting types from complex generic structures.
// infer extracts a type from within a pattern
// Get the return type of a function (like ReturnType<T>)
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
// Get the first parameter type
type FirstArg<T> = T extends (first: infer F, ...rest: any[]) => any ? F : never;
type FA = FirstArg<(name: string, age: number) => void>; // string
// Get the resolved value of a Promise (like Awaited)
type Unwrap<T> = T extends Promise<infer U> ? Unwrap<U> : T;
type Deep = Unwrap<Promise<Promise<Promise<number>>>>; // number (recursive!)
// Extract the value type from a Map
type MapValue<M> = M extends Map<any, infer V> ? V : never;
type MV = MapValue<Map<string, number>>; // number
// Extract element type from Set
type SetElement<S> = S extends Set<infer E> ? E : never;
// Get the instance type from a constructor
type Instance<T> = T extends new (...args: any[]) => infer I ? I : never;Distributive Conditional Types
Conditional types distribute over unions: applying ToArray<A | B> gives ToArray<A> | ToArray<B>, not (A | B)[]. This is how Exclude and NonNullable filter union members — they return 'never' for excluded types, which collapses in the union. To prevent distribution, wrap both sides in brackets: [T] extends [U]. Distribution is usually what you want for filtering, but non-distributive is needed for 'wrap the whole union' operations.
// Conditional types DISTRIBUTE over unions
// T extends U ? X : Y applied to A | B becomes
// (A extends U ? X : Y) | (B extends U ? X : Y)
type ToArray<T> = T extends any ? T[] : never;
type Result = ToArray<string | number>;
// ToArray<string> | ToArray<number>
// = string[] | number[]
// WITHOUT distribution (wrap in brackets to prevent):
type ToArrayNonDist<T> = [T] extends [any] ? T[] : never;
type Result2 = ToArrayNonDist<string | number>;
// (string | number)[] — a single array of the union
// Practical: filter out types from a union
type ExcludeNull<T> = T extends null | undefined ? never : T;
type Cleaned = ExcludeNull<string | null | number | undefined>;
// string | number (null and undefined filtered out)
// This is exactly how NonNullable<T> works:
// type NonNullable<T> = T extends null | undefined ? never : T;Conditional Type Constraints
Conditional types can be nested to create type-level discrimination (like a switch statement for types). Combined with infer, they extract and derive types from generic parameters. This is how libraries like React derive prop types from component definitions, and how routing libraries extract parameter types from path strings. The constraint (T extends any[]) ensures the input is valid before the conditional extracts the element type.
// Use conditional types to constrain and derive types
// Get the props type of a React component
type PropsOf<C> = C extends React.ComponentType<infer P> ? P : never;
type ButtonProps = PropsOf<typeof Button>; // the component's prop type
// Conditional with multiple branches (nested)
type TypeName<T> =
T extends string ? "string" :
T extends number ? "number" :
T extends boolean ? "boolean" :
T extends undefined ? "undefined" :
T extends Function ? "function" :
"object";
type T1 = TypeName<string>; // "string"
type T2 = TypeName<() => void>; // "function"
type T3 = TypeName<{}>; // "object"
// Constraint + conditional: only allow arrays, get element type
function firstElement<T extends any[]>(arr: T): T extends (infer E)[] ? E : never {
return arr[0] as any;
}
const x: number = firstElement([1, 2, 3]); // numberTemplate Literal Types (String Manipulation)
Template literal types enable type-level string manipulation — concatenation, case conversion, and pattern matching. Combined with conditional types and infer, they can parse path strings to extract route parameters, generate event handler names, or build type-safe property accessors. This is how frameworks like Next.js and tRPC create end-to-end type-safe APIs from string literals.
// Template literal types: type-level string operations
type Greeting = `Hello ${string}`;
const g: Greeting = "Hello World"; // OK
// const bad: Greeting = "Hi World"; // Error
// Uppercase, Lowercase, Capitalize, Uncapitalize
type Upper = Uppercase<"hello">; // "HELLO"
type Lower = Lowercase<"WORLD">; // "world"
type Cap = Capitalize<"foo">; // "Foo"
// Build event handler names from event names
type EventName = "click" | "focus" | "blur";
type HandlerName = `on${Capitalize<EventName>}`;
// "onClick" | "onFocus" | "onBlur"
// Extract route parameters
type ExtractParams<Path extends string> =
Path extends `${infer _Start}/${infer Param}/${infer Rest}`
? Param | ExtractParams<`/${Rest}`>
: Path extends `${infer _Start}/${infer Param}`
? Param
: never;
type Params = ExtractParams<"/users/:id/posts/:postId">;
// ":id" | ":postId"
// Property accessor type: "a.b.c" -> nested type
type Get<T, P extends string> =
P extends `${infer Key}.${infer Rest}`
? Key extends keyof T ? Get<T[Key], Rest> : never
: P extends keyof T ? T[P] : never;Mapped Types
Basic Mapped Types
Mapped types iterate over an object's keys and transform each property — [K in keyof T] is the syntax. They're how Partial, Readonly, Pick, and other utility types are implemented. You can modify the property type (T[K] | null), add modifiers (? or readonly), or completely replace the value type. Mapped types are the backbone of TypeScript's type transformation system.
// Mapped types transform each property of an existing type
// Make all properties optional (like Partial<T>)
type MyPartial<T> = {
[K in keyof T]?: T[K];
};
// Make all properties readonly (like Readonly<T>)
type MyReadonly<T> = {
readonly [K in keyof T]: T[K];
};
// Make all properties nullable
type Nullable<T> = {
[K in keyof T]: T[K] | null;
};
interface User {
id: number;
name: string;
email: string;
}
type NullableUser = Nullable<User>;
// { id: number | null; name: string | null; email: string | null }
// Change all property types to a specific type
type Stringify<T> = {
[K in keyof T]: string;
};
type StringUser = Stringify<User>;
// { id: string; name: string; email: string }Key Remapping via 'as'
Key remapping (as clause, TS 4.1+) lets you rename or filter keys during mapping. Use template literal types to transform key names (add prefixes, convert to getters, uppercase). Returning 'never' for a key removes it — this is how you filter properties. Combined with conditional types, key remapping enables powerful transformations like converting a data schema to a validation schema or an API type to a form type.
// Remap keys using 'as' (TypeScript 4.1+)
// Add a prefix to all keys
type Prefix<T, P extends string> = {
[K in keyof T as `${P}${Capitalize<string & K>}`]: T[K];
};
interface User {
id: number;
name: string;
}
type PrefixedUser = Prefix<User, "user">;
// { userId: number; userName: string }
// Convert keys to getters
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type UserGetters = Getters<User>;
// { getId: () => number; getName: () => string }
// Filter out keys by returning never
type RemoveKindField<T> = {
[K in keyof T as Exclude<K, "kind">]: T[K];
};
type WithoutKind = RemoveKindField<{ kind: "circle"; radius: number }>;
// { radius: number }
// Make keys UPPERCASE
type UpperKeys<T> = {
[K in keyof T as Uppercase<string & K>]: T[K];
};Modifiers: +, -, ?, readonly
The + and - modifiers add or remove property modifiers. -? removes optionality (making optional fields required); -readonly removes immutability. These are how Required<T> and the Mutable pattern work. The + prefix is optional (readonly is the same as +readonly), but - is required for removal. This gives fine-grained control over property characteristics during type transformations.
// Add (+) or remove (-) modifiers
// Remove optional (?) modifier: -?
type Concrete<T> = {
[K in keyof T]-?: T[K];
};
interface OptionalUser {
id?: number;
name?: string;
}
type RequiredUser = Concrete<OptionalUser>;
// { id: number; name: string } — all required now
// Remove readonly modifier: -readonly
type Mutable<T> = {
-readonly [K in keyof T]: T[K];
};
interface FrozenConfig {
readonly port: number;
readonly host: string;
}
type EditableConfig = Mutable<FrozenConfig>;
// { port: number; host: string } — mutable now
// Add readonly modifier: +readonly (or just readonly)
type Freeze<T> = {
+readonly [K in keyof T]: T[K];
};
// Add optional modifier: +?
type MakeOptional<T> = {
[K in keyof T]+?: T[K];
};Homomorphic Mapped Types
Homomorphic mapped types ([K in keyof T]) preserve property modifiers (readonly, ?) from the source type — this is why Pick<User, 'id'> keeps id readonly. Non-homomorphic mappings (e.g., [K in string]) don't preserve modifiers. This matters when deriving types: a homomorphic Partial of a type with readonly fields keeps those fields readonly (but optional). Understanding homomorphism helps predict whether modifiers survive a transformation.
// Homomorphic mapped types preserve modifiers from the original type
interface User {
readonly id: number;
name: string;
email?: string;
}
// Homomorphic: [K in keyof T] — preserves readonly and ?
type Clone<T> = {
[K in keyof T]: T[K];
};
type ClonedUser = Clone<User>;
// { readonly id: number; name: string; email?: string }
// Modifiers are PRESERVED from User
// Non-homomorphic: doesn't preserve modifiers
type NonHomo<T> = {
[K in string]: T; // not keyof T — loses modifiers
};
// Pick is homomorphic — preserves modifiers
type UserPick = Pick<User, "id" | "name">;
// { readonly id: number; name: string } — readonly preserved!
// Practical: create a "patch" type that preserves optionality
type Patch<T> = {
[K in keyof T]?: T[K]; // Partial is homomorphic
};Building a Validation Type from a Schema
This is how form libraries (React Hook Form, Formik) and validation libraries (Zod, Yup) maintain type safety — they derive validator and form types from your data interfaces using mapped types. When you add a field to User, the validator and form types automatically require it too, preventing drift. This demonstrates the real-world power of mapped types: one source of truth (the interface) drives multiple derived types.
// Practical: derive a validator type from a data type
// Given a data interface
interface User {
id: number;
name: string;
email: string;
}
// Create a validator type where each field is a validation function
type Validator<T> = {
[K in keyof T]: (value: T[K]) => boolean;
};
const userValidator: Validator<User> = {
id: (v) => v > 0,
name: (v) => v.length > 0,
email: (v) => v.includes("@"),
};
// Create a form type where fields are wrapped in a FormField
type FormField<T> = {
value: T;
error: string | null;
touched: boolean;
};
type Form<T> = {
[K in keyof T]: FormField<T[K]>;
};
const userForm: Form<User> = {
id: { value: 1, error: null, touched: false },
name: { value: "Alice", error: null, touched: true },
email: { value: "", error: "Required", touched: true },
};
// The form type is always in sync with User — add a field to
// User and the form type automatically requires it too.Type Guards & Narrowing
typeof & instanceof Narrowing
TypeScript narrows types based on runtime checks. typeof narrows primitives (string, number, boolean, etc.); instanceof narrows class instances. Truthiness checks (if (value)) narrow out null/undefined/0/''/false. The narrowing applies within the branch where the condition holds. This is how TypeScript makes runtime checks carry type information, eliminating the need for explicit casts.
// typeof narrows primitive types
function padLeft(value: string | number, padding: string | number) {
if (typeof padding === "number") {
return " ".repeat(padding) + value;
// padding is narrowed to 'number' here
}
return padding + value;
// padding is narrowed to 'string' here
}
// instanceof narrows class types
class Cat { meow(): void {} }
class Dog { bark(): void {} }
function speak(animal: Cat | Dog) {
if (animal instanceof Cat) {
animal.meow(); // OK — narrowed to Cat
} else {
animal.bark(); // OK — narrowed to Dog
}
}
// typeof returns: "string" | "number" | "boolean" | "symbol"
// "bigint" | "undefined" | "object" | "function"
// Note: typeof null === "object" (historical JS bug)
// Truthiness narrowing
function process(value?: string) {
if (value) {
console.log(value.toUpperCase()); // value is string (not undefined)
}
}in Operator & Discriminated Unions
The 'in' operator narrows based on property existence. Discriminated unions use a shared literal property (like 'kind' or 'type') as a tag — switching on it narrows to the correct variant with full property access. This is the TypeScript equivalent of sum types / algebraic data types. It's the standard pattern for Redux actions, state machines, and API responses with multiple shapes. Always use a literal type for the discriminant.
// 'in' operator checks for a property — narrows to types that have it
interface Fish { swim(): void; }
interface Bird { fly(): void; }
function move(animal: Fish | Bird) {
if ("swim" in animal) {
animal.swim(); // narrowed to Fish
} else {
animal.fly(); // narrowed to Bird
}
}
// Discriminated unions: a shared literal property (the 'discriminant')
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; size: number }
| { kind: "rectangle"; width: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2; // narrowed by kind
case "square":
return shape.size ** 2;
case "rectangle":
return shape.width * shape.height;
}
}
// Discriminated unions are the idiomatic TS pattern for
// variant data (like Redux actions, AST nodes, API responses)Custom Type Guard Functions
Custom type guards (return type 'x is Type') let you encapsulate complex runtime checks into reusable functions that narrow types. Assertion functions (asserts x is T) throw instead of returning boolean — they narrow in all code after the call. Use type guards to validate untrusted data (JSON.parse, API responses) and bring it into the type system. This bridges the gap between runtime validation and compile-time types.
// A type guard function has a special return type: 'x is Type'
// It narrows the type when it returns true
interface User {
id: number;
name: string;
}
function isUser(obj: any): obj is User {
return (
typeof obj === "object" &&
obj !== null &&
typeof obj.id === "number" &&
typeof obj.name === "string"
);
}
const data: unknown = JSON.parse('{"id": 1, "name": "Alice"}');
if (isUser(data)) {
console.log(data.name); // data is narrowed to User
}
// Type guard for arrays
function isStringArray(arr: unknown): arr is string[] {
return Array.isArray(arr) && arr.every((x) => typeof x === "string");
}
// Type guard for discriminated unions
type Result<T> =
| { success: true; data: T }
| { success: false; error: string };
function isSuccess<T>(r: Result<T>): r is { success: true; data: T } {
return r.success;
}
// Assertion functions (TS 3.7+): throw if condition fails
function assertDefined<T>(value: T | null | undefined): asserts value is T {
if (value === null || value === undefined) {
throw new Error("Expected value to be defined");
}
}Exhaustiveness Checking with never
Exhaustiveness checking uses the 'never' type to ensure all union variants are handled. If you add a new variant to the union but forget a case, the default branch's 'never' assignment becomes a compile error. The assertNever helper throws at runtime and errors at compile time for missing cases. This is the most valuable pattern for discriminated unions — it makes the compiler tell you when you've forgotten a case.
// The 'never' type ensures all cases are handled
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; size: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.size ** 2;
default:
// If we add a new shape but forget a case, this line
// becomes a compile error because 'shape' would not be 'never'
const _exhaustive: never = shape;
return _exhaustive;
}
}
// Adding a new variant without updating area():
// type Shape = ... | { kind: "triangle"; base: number; height: number }
// Error: Type '{ kind: "triangle"; ... }' is not assignable to type 'never'
// Helper function pattern
function assertNever(x: never): never {
throw new Error("Unexpected: " + JSON.stringify(x));
}
function process(shape: Shape) {
switch (shape.kind) {
case "circle": return;
case "square": return;
default: return assertNever(shape); // compile error if a case is missing
}
}Narrowing with Array Methods
Array.filter doesn't narrow element types by default because its callback returns boolean, not a type guard. To narrow, pass a custom type guard function (pet is Dog) — then filter returns the narrowed array type. TypeScript also narrows inside callback bodies (forEach, map) based on if-checks. Array.isArray is a built-in type guard that narrows unknown/any to an array type.
// TypeScript narrows through filter, map, and control flow
interface Pet {
name: string;
speak(): void;
}
class Dog implements Pet {
name = "Rex";
speak() { console.log("Woof"); }
fetch() { console.log("Fetching!"); }
}
class Cat implements Pet {
name = "Whiskers";
speak() { console.log("Meow"); }
}
const pets: Pet[] = [new Dog(), new Cat(), new Dog()];
// filter doesn't narrow by default (callback return type is boolean)
// Use a type guard to narrow:
function isDog(pet: Pet): pet is Dog {
return pet instanceof Dog;
}
const dogs = pets.filter(isDog); // Dog[] — properly narrowed!
dogs.forEach((d) => d.fetch()); // OK — d is Dog
// Narrowing in forEach/callbacks
pets.forEach((pet) => {
if (isDog(pet)) {
pet.fetch(); // narrowed to Dog inside callback
}
});
// Array.isArray narrows 'unknown' to an array
function process(input: unknown) {
if (Array.isArray(input)) {
input.length; // OK — input is any[] (or unknown[])
}
}Type Inference
Variable & Return Type Inference
TypeScript infers types from initializers and return statements, so you rarely need explicit annotations. Variables widen to their general type (let x = 10 infers number, not 10). 'as const' prevents widening: it makes literals stay literal, objects readonly, and arrays become readonly tuples. typeof colors[number] extracts a union of tuple element types — a common pattern for deriving enum-like types from arrays.
// TypeScript infers types when you don't annotate
// Variable inference
let count = 0; // number
let name = "Alice"; // string
let items = [1, 2, 3]; // number[]
let mixed = [1, "two"]; // (string | number)[]
// Function return type inference
function add(a: number, b: number) {
return a + b; // return type inferred as number
}
// const assertions for literal types
const x = 10; // number (widened)
const y = "hello"; // string (widened)
const z = { a: 1 }; // { a: number }
const x2 = 10 as const; // 10 (literal type)
const y2 = "hello" as const; // "hello" (literal type)
const z2 = { a: 1 } as const; // { readonly a: 10 }
// Array with 'as const' becomes a readonly tuple
const colors = ["red", "green", "blue"] as const;
// readonly ["red", "green", "blue"]
type Color = typeof colors[number]; // "red" | "green" | "blue"Contextual Typing
Contextual typing flows the expected type backward into expressions. When you assign a function to a typed variable, the parameter types are inferred from the target type. This is why event handlers, array callbacks, and object literals often need no type annotations. The rule of thumb: annotate function signatures (parameters and return types for public APIs), but let inference handle locals and callbacks.
// Contextual typing: the expected type influences the inferred type
// Without context, the parameter type is unknown
window.onmousedown = (mouseEvent) => {
// mouseEvent is inferred as MouseEvent (from onmousedown's type)
console.log(mouseEvent.button);
};
// Array map: callback params inferred from array type
const nums = [1, 2, 3];
const doubled = nums.map((n) => n * 2); // n is number, result is number[]
// Contextual typing for object literals
interface User {
name: string;
age: number;
}
const user: User = {
name: "Alice", // inferred as string (from User.name)
age: 30,
};
// Contextual typing with discriminated unions
type Event =
| { type: "click"; x: number; y: number }
| { type: "scroll"; scrollTop: number };
function handle(e: Event) {
if (e.type === "click") {
console.log(e.x, e.y); // narrowed
}
}
// Best practice: let inference work for you — don't over-annotate
const nums2 = [1, 2, 3].map((n) => n * 2); // number[] — no annotation neededBest Common Type (Union Inference)
When inferring from multiple values (like array literals), TypeScript finds the 'best common type' — usually a supertype or a union. An array of [Dog, Cat] infers as Animal[] (the common base), not (Dog | Cat)[]. To get a union, annotate explicitly. Conditional returns infer the union of all branches. Understanding this helps predict when you need explicit annotations vs when inference suffices.
// When inferring from multiple values, TS finds the best common type
// Array of same type: inferred as that type
const nums = [1, 2, 3]; // number[]
// Array of different types: inferred as union
const mixed = [1, "two", true]; // (string | number | boolean)[]
// Array of subclasses: inferred as the common supertype
class Animal { name: string; }
class Dog extends Animal { bark(): void {} }
class Cat extends Animal { meow(): void {} }
const pets = [new Dog(), new Cat()]; // Animal[] (not (Dog | Cat)[])
// To get a union instead, use an explicit type annotation:
const pets2: (Dog | Cat)[] = [new Dog(), new Cat()];
// Or use 'as const' for readonly tuples:
const tuple = [new Dog(), new Cat()] as const;
// readonly [Dog, Cat]
// Return type inference with conditionals
function getValue(flag: boolean) {
return flag ? 42 : "hello"; // inferred as number | string
}Control Flow Analysis
TypeScript performs control flow analysis — it tracks how types narrow and widen through if/else, returns, assignments, and logical operators. A type narrows after a check and stays narrowed until the variable is reassigned. Early returns (guard clauses) are especially effective: after 'if (value === null) return', the rest of the function knows value isn't null. This is why guard-clause style code works so well with TypeScript.
// TypeScript tracks types through control flow
function example(value: string | number | null) {
// After this check, 'value' is string | number in this branch
if (value === null) {
return;
}
// value is now string | number (null narrowed out)
console.log(value);
if (typeof value === "string") {
return value.toUpperCase(); // value is string
}
// value is now number (string and null narrowed out)
return value.toFixed(2);
}
// Narrowing through assignments
let x: string | number;
x = "hello";
console.log(x.toUpperCase()); // x is string
x = 42;
console.log(x.toFixed(2)); // x is number
// Narrowing with && and ||
function process(input?: string) {
const value = input && input.trim(); // string | undefined
const safe = input || "default"; // string (undefined narrowed out)
}
// Narrowing is reset when a variable is reassigned
let v: string | number = "hi";
v.toUpperCase(); // string
v = 42;
// v.toUpperCase(); // Error: number doesn't have toUpperCasesatisfies Operator (TS 4.9+)
The 'satisfies' operator (TS 4.9+) validates that a value conforms to a type while preserving the most specific inferred type — unlike type annotations which widen. This is ideal for configs, route maps, and theme objects: you get compile-time validation that the structure is correct, but property access still returns the precise literal type. Combine with 'as const' for both literal preservation and structural validation.
// 'satisfies' checks a value matches a type WITHOUT widening it
// Problem: annotation widens the type
const config1: Record<string, string | number> = {
port: 8080,
host: "localhost",
};
// config1.port is 'string | number' (widened — lost the literal)
// Solution: 'satisfies' validates but preserves the inferred type
const config2 = {
port: 8080,
host: "localhost",
} satisfies Record<string, string | number>;
// config2.port is 'number' (preserved!) but still validated
// Practical: type-safe route configs
type Routes = Record<string, { method: string; handler: () => void }>;
const routes = {
"/users": { method: "GET", handler: () => fetchUsers() },
"/posts": { method: "POST", handler: () => createPost() },
} satisfies Routes;
routes["/users"].method; // string (from Routes)
routes["/users"].handler; // () => void (from Routes)
// 'as const' + 'satisfies': literal types + validation
const colors = {
primary: "#ff0000",
secondary: "#00ff00",
} as const satisfies Record<string, `#${string}`;Declaration Files & Module Augmentation
Writing .d.ts Declaration Files
.d.ts files contain type declarations (no implementation) — they describe the types of JavaScript code. Use 'declare module' to add types for untyped npm packages. 'declare global' extends global types like Window. Ambient declarations tell TypeScript 'this exists at runtime, trust me'. This is how you integrate legacy JS, browser APIs, and build-time injected variables into the type system.
// types/my-module.d.ts — describes the types of a JS library
// Declare a module (for libraries without types)
declare module "untyped-lib" {
export function greet(name: string): string;
export const version: string;
export interface Config {
timeout: number;
retries: number;
}
}
// Ambient declarations for global variables
declare global {
interface Window {
myCustomProp: string;
myApp: { init: () => void };
}
}
// Now window.myCustomProp is typed
// window.myCustomProp; // string
// Declaring a global function
declare function myGlobalFn(x: number): string;
// Declaring a global namespace
declare namespace MyLib {
function doSomething(): void;
const version: string;
}
// Use 'declare' for things that exist at runtime but not in TS
// Common for: legacy JS, browser globals, build-time constantsModule Augmentation (Extending Existing Types)
Module augmentation extends existing types from other modules — adding properties to interfaces without modifying the original source. This is how Express middleware (like passport) adds req.user, and how you extend third-party library types. The 'declare module' syntax reopens the module's type space. Augmentations must be in a module (a file with import/export) to take effect globally.
// Module augmentation: add to an existing module's types
// Extend an interface from another module
import express from "express";
declare module "express" {
interface Request {
user?: {
id: number;
name: string;
};
// Now req.user is typed on all Express requests
}
}
// Augment a third-party module
declare module "axios" {
export interface AxiosRequestConfig {
retryCount?: number; // add a custom config option
}
}
// Augment a global interface
declare global {
interface Array<T> {
last(): T | undefined; // add a custom array method
}
}
// Implementation (in a .ts file, not .d.ts)
Array.prototype.last = function () {
return this[this.length - 1];
};
[1, 2, 3].last(); // 3 — now typed!
// Module augmentation is how middleware adds typed properties
// to req/res objects in Express, Fastify, etc.Triple-Slash Directives
Triple-slash directives (///) are special compiler comments that instruct TypeScript to include additional files or type packages. The most common is /// <reference types='node' /> for including @types/node. With modern tsconfig.json 'types' and 'lib' options, these are rarely needed — prefer config-based settings. They're mainly seen in .d.ts files and legacy code. Understanding them helps when reading declaration files.
// Triple-slash directives are special comments processed by TS
/// <reference path="./other-types.d.ts" />
// Includes another declaration file (rarely needed with modules)
/// <reference types="node" />
// Includes types from @types/node (like process, Buffer, __dirname)
/// <reference lib="es2020" />
// Includes a built-in lib (alternative to tsconfig "lib")
// Most common use: referencing @types packages
// In a .d.ts file for a package that needs Node types:
/// <reference types="node" />
declare function readFile(path: string): Buffer; // Buffer from @types/node
// NOTE: With modern TS and tsconfig "types" and "lib" options,
// triple-slash directives are rarely needed. They're mostly
// used in .d.ts files for backward compatibility.
// Prefer tsconfig.json settings:
// {
// "compilerOptions": {
// "types": ["node"], // instead of /// <reference types="node" />
// "lib": ["es2020", "dom"] // instead of /// <reference lib="es2020" />
// }
// }Publishing Types with a Package
To publish TypeScript types with your npm package, set 'types' in package.json to point to your .d.ts file and enable 'declaration: true' in tsconfig. Consumers automatically get types when they install your package. declarationMap enables 'Go to Definition' to jump to the source .ts file. For libraries without bundled types, the DefinitelyTyped project (@types/package) provides community-maintained declarations.
// package.json for a library with TypeScript types
{
"name": "my-library",
"version": "1.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts", // <-- points to the type declarations
"files": ["dist"],
"scripts": {
"build": "tsc",
"prepublishOnly": "npm run build"
}
}
// tsconfig.json for building a library
{
"compilerOptions": {
"declaration": true, // generate .d.ts files
"declarationMap": true, // generate .d.ts.map (source maps for types)
"sourceMap": true, // generate .js.map
"outDir": "./dist",
"rootDir": "./src",
"composite": true // enable project references
}
}
// Consumers get types automatically when they 'npm install my-library'
import { myFunction } from "my-library"; // fully typed!
// For libraries without bundled types, install @types package:
// npm install --save-dev @types/express
// Check if types exist: https://www.typescriptlang.org/dt/searchType-Only Imports & Exports
'import type' imports only type information — it's completely erased at runtime, reducing bundle size and avoiding circular dependency issues. Use it for interfaces, type aliases, and type-only re-exports. The inline 'import { x, type Y }' syntax (TS 4.5+) mixes value and type imports cleanly. verbatimModuleSyntax (TS 5.0+) enforces this strictly. Prefer 'import type' whenever you're importing something used only in type positions.
// 'import type' imports only types (erased at runtime, no JS emitted)
import type { User, Config } from "./types";
import type { ReactNode } from "react";
// These are erased completely — no runtime import in the output JS
// Useful for: reducing bundle size, avoiding circular dependencies
// Mixed import: value + type
import { createStore, type Store } from "redux";
// createStore is a runtime import; Store is type-only
// 'export type' re-exports types only
export type { User, Config } from "./types";
// Interface vs Type for declarations
interface IUser { name: string; } // can be augmented/merged
type TUser = { name: string }; // cannot be merged, more flexible
// VerbatimModuleSyntax (TS 5.0+): enforces 'import type' for type-only
// imports, ensuring they're always elided
// {
// "compilerOptions": { "verbatimModuleSyntax": true }
// }tsconfig.json Options
Core Compiler Options
The tsconfig.json controls how TypeScript compiles. 'target' sets the output JS version; 'module' sets the module system. 'strict: true' is the single most important setting — it enables all strict type checks (noImplicitAny, strictNullChecks, etc.). 'lib' determines which built-in APIs are available (DOM for browser, ES2022 for modern JS features). Always start new projects with strict: true.
{
"compilerOptions": {
"target": "ES2022", // JS version to compile to
"module": "ESNext", // module system (ESNext, CommonJS, etc.)
"moduleResolution": "bundler", // how modules are resolved
"lib": ["ES2022", "DOM"], // available type definitions
"outDir": "./dist", // output directory for compiled JS
"rootDir": "./src", // root of source files
"sourceMap": true, // generate .map files for debugging
"declaration": true, // generate .d.ts files (for libraries)
"removeComments": true, // strip comments from output
// Type checking strictness
"strict": true, // enable ALL strict checks (recommended)
"noImplicitAny": true, // error on implicit 'any'
"strictNullChecks": true, // null/undefined are separate types
"noUnusedLocals": true, // error on unused local variables
"noUnusedParameters": true,// error on unused function params
"noImplicitReturns": true, // error if not all paths return
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true, // allow default imports from CommonJS
"skipLibCheck": true, // skip type checking of .d.ts files
"forceConsistentCasingInFileNames": true
}
}Strict Mode Flags Explained
strict mode is a bundle of strictness flags. strictNullChecks is the most impactful — it makes null/undefined distinct types, forcing you to handle them explicitly (the #1 source of runtime crashes). noImplicitAny prevents silent type erosion. strictPropertyInitialization catches uninitialized class fields (use ! for definite assignment or initialize in constructor). Always enable strict mode in new projects — the upfront cost is worth the safety.
{
"compilerOptions": {
// 'strict: true' enables ALL of these:
"strictNullChecks": true,
// null and undefined are NOT assignable to other types
// without explicit union. Forces null handling.
let x: string = null; // ERROR (without this, it's allowed)
"noImplicitAny": true,
// Parameters/variables can't be implicitly 'any'
function fn(x) { } // ERROR: x is implicitly any
"strictFunctionTypes": true,
// Function parameter types checked contravariantly
// (catches unsafe function assignments)
"strictBindCallApply": true,
// bind/call/apply are type-checked
"strictPropertyInitialization": true,
// Class properties must be initialized or declared with !
class User {
name: string; // ERROR: not initialized
name2!: string; // OK: definite assignment assertion
}
"noImplicitThis": true,
// 'this' must have a known type (no implicit any)
"alwaysStrict": true
// Emit "use strict" in every file
}
}Module Resolution Strategies
moduleResolution controls how import paths are resolved. 'node' is the classic strategy; 'bundler' (TS 5.0+) matches modern bundlers like Vite and supports package.json exports. 'nodenext' is strict ESM (requires extensions). paths lets you create import aliases (@/ → src/), which must be mirrored in your bundler config (e.g., Vite's resolve.alias). baseUrl + paths is the standard way to avoid deep relative imports (../../../).
{
"compilerOptions": {
// How TS resolves import paths
"moduleResolution": "node", // classic Node.js resolution
// Looks for: file.ts, file/index.ts, node_modules/file
"moduleResolution": "bundler", // for Vite/webpack/esbuild (TS 5.0+)
// Matches how bundlers resolve: supports import maps,
// conditional exports, no file extension requirement
"moduleResolution": "nodenext", // Node.js ESM resolution (strict)
// Requires file extensions in imports: import "./foo.js"
// Path mapping (aliases)
"baseUrl": ".",
"paths": {
"@/*": ["src/*"], // import "@/components/Button"
"@utils/*": ["src/utils/*"],
"@components": ["src/components/index.ts"]
}
// rootDirs: virtual directories that map to the same location
"rootDirs": ["src", "generated"],
// imports between src/ and generated/ resolve as if same dir
}
}
// In your code:
import { Button } from "@/components/Button";
import { formatDate } from "@utils/date";
// These resolve to src/components/Button.ts and src/utils/date.tsProject References (Monorepos)
Project references split a large codebase into independently compiled sub-projects — essential for monorepos. Each project has composite: true and emits declarations. References declare dependencies between projects. tsc --build (-b) compiles in dependency order, only rebuilding what changed. This dramatically speeds up type-checking for large codebases and enforces architectural boundaries between packages.
// tsconfig.json (root — references sub-projects)
{
"files": [],
"references": [
{ "path": "./packages/shared" },
{ "path": "./packages/api" },
{ "path": "./packages/web" }
]
}
// packages/shared/tsconfig.json
{
"compilerOptions": {
"composite": true, // required for project references
"declaration": true, // must emit declarations
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}
// packages/api/tsconfig.json
{
"compilerOptions": { /* ... */ },
"references": [
{ "path": "../shared" } // depends on shared
]
}
// Benefits:
// 1. Faster builds — only rebuild changed projects
// 2. Clear dependency boundaries between packages
// 3. Type-checking is scoped per project
// 4. tsc --build handles the build order automatically
// Build command: tsc --build (or tsc -b)Common tsconfig Recipes
Different project types need different tsconfig settings. React/Vite uses jsx: 'react-jsx' and noEmit (Vite compiles). Node.js uses CommonJS (or NodeNext for ESM) and types: ['node']. Libraries need declaration: true for .d.ts output and a lower target for broader compatibility. isolatedModules is required by Vite/esbuild (each file must be independently compilable). Always exclude test files and node_modules from the build.
// React + Vite project
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx", // React 17+ JSX transform
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"noEmit": true, // Vite handles compilation
"isolatedModules": true, // required by Vite/esbuild
"verbatimModuleSyntax": true
},
"include": ["src"]
}
// Node.js backend project
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS", // or NodeNext for ESM
"moduleResolution": "node",
"lib": ["ES2022"],
"types": ["node"], // @types/node
"outDir": "./dist",
"strict": true,
"esModuleInterop": true
},
"include": ["src"]
}
// Library project (publishes to npm)
{
"compilerOptions": {
"target": "ES2020", // broader compatibility
"module": "ESNext",
"declaration": true, // emit .d.ts
"declarationMap": true,
"outDir": "./dist",
"strict": true
},
"include": ["src"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}Decorators
Class Decorator
Class decorators receive the constructor and can return a modified class. They are experimental (require experimentalDecorators: true). Common in NestJS and TypeORM.
function logged<T extends new (...args: any[]) => any>(con: T) {
return class extends con {
created = new Date().toISOString();
};
}
@logged
class Service { constructor(public name: string) {} }Method Decorator
Method decorators receive (target, key, descriptor). Wrapping descriptor.value enables logging, caching, validation. This is how NestJS interceptors work.
function log(target: any, key: string, desc: PropertyDescriptor) {
const orig = desc.value;
desc.value = function(...args: any[]) {
console.log(`Calling ${key}`, args);
return orig.apply(this, args);
};
}
class Calc { @log add(a: number, b: number) { return a + b; } }Property Decorator
Property decorators receive (target, key). Using Object.defineProperty creates getters/setters for validation. Used in class-validator for DTO validation.
function required(target: any, key: string) {
let val: any;
Object.defineProperty(target, key, {
get() { return val; },
set(v: any) { if (v == null) throw new Error(`${key} required`); val = v; }
});
}Parameter Decorator
Parameter decorators receive (target, methodKey, parameterIndex). Used with metadata reflection for validation. class-validator and NestJS use this.
function Min(min: number) {
return (target: any, key: string, idx: number) => {
console.log(`${key} param ${idx} >= ${min}`);
};
}
class Order { create(@Min(0) qty: number) { return qty; } }Decorator Factory
Decorator factories return a decorator function, enabling configuration. The outer function receives parameters, the inner is the actual decorator.
function Retry(times: number) {
return function(target: any, key: string, desc: PropertyDescriptor) {
const orig = desc.value;
desc.value = async function(...args: any[]) {
for (let i = 0; i < times; i++)
try { return await orig.apply(this, args); }
catch (e) { if (i === times - 1) throw e; }
};
};
}Module Augmentation
Augment Built-in Types
Module augmentation extends existing types. declare global allows augmenting built-in types like Array. The runtime implementation must also be provided.
declare global {
interface Array<T> {
last(): T | undefined;
chunk(size: number): T[][];
}
}
Array.prototype.last = function() { return this[this.length - 1]; };Augment Library Types
Module augmentation extends types from third-party libraries. declare module reopens the module type. Essential for adding custom properties to framework objects.
declare module 'express' {
interface Request {
user?: { id: string; role: string };
}
}
app.get('/profile', (req, res) => {
const userId = req.user?.id; // Typed!
});Augment Window
Augmenting Window adds custom global properties with type safety. Useful for exposing app state to debugging tools or analytics.
declare global {
interface Window {
myApp: { init: () => void; version: string };
}
}
window.myApp = { init: () => console.log('Ready'), version: '1.0.0' };CSS Modules
CSS Modules need type declarations. The declaration maps .module.css imports to a record of class names. Enables autocompletion for CSS class references.
declare module '*.module.css' {
const classes: { readonly [key: string]: string };
export default classes;
}
import styles from './Button.module.css';
<button className={styles.button} />Vue Plugin
Vue and other frameworks use module augmentation for plugin typing. ComponentCustomProperties adds instance properties. Enables type-safe plugins.
declare module 'vue' {
interface ComponentCustomProperties {
$auth: { login: () => Promise<void> };
}
}
export default defineComponent({
methods: { async login() { await this.$auth.login(); } }
});Declaration Merging
Merging Interfaces
Interfaces with the same name are automatically merged. All members become part of a single interface. Useful for splitting interfaces across files.
interface User { name: string; }
interface User { age: number; }
interface User { email: string; }
const user: User = { name: 'Alice', age: 30, email: '[email protected]' };Merging Namespaces
Namespaces with the same name merge their exports. This allows splitting namespace contents across files. ES modules are preferred for new code.
namespace Utils {
export function log(msg: string) { console.log(msg); }
}
namespace Utils {
export function warn(msg: string) { console.warn(msg); }
}
Utils.log('info'); Utils.warn('alert');Namespace with Function
Namespaces can merge with functions, classes, and enums. The namespace adds static properties to the function. Used in Moment.js and similar libraries.
function Counter() { Counter.count++; }
namespace Counter {
export let count = 0;
export function reset() { count = 0; }
}
Counter(); Counter();
console.log(Counter.count); // 2Merging with Classes
Merging a namespace with a class adds static members and nested types. The namespace can export interfaces that become nested types.
class Settings { static defaults = { theme: 'light' }; }
namespace Settings {
export interface Options { theme: string; lang: string; }
}
const opts: Settings.Options = { theme: 'dark', lang: 'en' };Disallowed Merges
Classes cannot merge with other classes. Variables cannot merge. Functions merge as overloads. Enums can merge with namespaces.
// Cannot merge classes
class A { x = 1; }
class A { y = 2; } // Error
// Function overloads (allowed)
function fn(x: string): string;
function fn(x: number): number;
function fn(x: any): any { return x; }Type Narrowing
typeof & instanceof
typeof narrows primitive types. instanceof narrows class types. TypeScript understands these checks and narrows the type in each branch.
function process(value: string | number | Date) {
if (typeof value === 'string') return value.toUpperCase();
if (typeof value === 'number') return value.toFixed(2);
if (value instanceof Date) return value.toISOString();
}in Operator
The in operator checks if a property exists, narrowing the type. Useful for discriminated unions with different property names.
interface Cat { meow(): void; }
interface Dog { bark(): void; }
function speak(animal: Cat | Dog) {
if ('meow' in animal) animal.meow();
else animal.bark();
}Discriminated Unions
Discriminated unions use a common property (discriminant) to narrow types. switch on the discriminant for exhaustive checking. Safest pattern for variant types.
type Result =
| { status: 'success'; data: string }
| { status: 'error'; message: string };
function handle(r: Result) {
switch (r.status) {
case 'success': console.log(r.data); break;
case 'error': console.log(r.message); break;
}
}Type Predicates
Type predicates (x is T) enable custom narrowing functions. Return true narrows to T, false narrows to the excluded type. TypeScript trusts the predicate blindly.
function isFish(pet: Fish | Bird): pet is Fish {
return (pet as Fish).swim !== undefined;
}
function move(pet: Fish | Bird) {
if (isFish(pet)) pet.swim();
else pet.fly();
}Assertion Functions
Assertion functions throw if the condition fails, narrowing the type for subsequent code. asserts x is T narrows to T. Eliminates redundant null checks.
function assertDefined<T>(value: T | null | undefined): asserts value is T {
if (value == null) throw new Error('Null or undefined');
}
function processUser(user?: User) {
assertDefined(user);
console.log(user.name); // User (not undefined)
}Template Literal Types
Basic Template Literals
Template literal types create string patterns. They constrain strings to match a template. Enables type-safe patterns for API endpoints and event names.
type Greeting = `hello ${string}`;
const g: Greeting = 'hello world'; // OK
type Endpoint = `${'GET' | 'POST'} /api/${string}`;
const ep: Endpoint = 'GET /api/users';Uppercase & Lowercase
Built-in intrinsic types transform string literal types. Combine with template literals to generate type-safe event names and constants.
type Upper = Uppercase<'hello'>; // 'HELLO'
type Lower = Lowercase<'WORLD'>; // 'world'
type Cap = Capitalize<'foo'>; // 'Foo'
type EventName = `on${Capitalize<'click'>}`; // 'onClick'Key Remapping
Key remapping (as clause) transforms keys during mapped types. Generates getter/setter names from property names. Creates type-safe APIs from interfaces.
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; }String Pattern Matching
Template literal types with infer can parse strings at compile time. Split breaks a string into a tuple. Enables type-safe string manipulation.
type Split<S extends string, D extends string> =
S extends `${infer L}${D}${infer R}` ? [L, ...Split<R, D>] : [S];
type Parts = Split<'a,b,c', ','>; // ['a', 'b', 'c']Event System Typing
Template literal types with generics create fully type-safe event systems. The event name determines the payload type. on and emit enforce matching types.
type EventMap = { click: { x: number }; submit: { value: string } };
class Emitter {
on<K extends keyof EventMap>(event: K, handler: (e: EventMap[K]) => void) {}
emit<K extends keyof EventMap>(event: K, data: EventMap[K]) {}
}
em.on('click', e => console.log(e.x)); // Typed!infer Keyword
Extract Return Type
infer declares a type variable within a conditional type. It captures the type at a specific position. ReturnType is the built-in equivalent.
type ReturnOf<T> = T extends (...args: any[]) => infer R ? R : never;
function getUser() { return { name: 'Alice', age: 30 }; }
type User = ReturnOf<typeof getUser>; // { name: string; age: number; }Extract Promise Type
infer extracts the inner type of a Promise. DeepUnwrap recursively unwraps nested Promises. The built-in Awaited<T> does this in modern TypeScript.
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type R = UnwrapPromise<Promise<string>>; // string
type DeepUnwrap<T> = T extends Promise<infer U> ? DeepUnwrap<U> : T;
type D = DeepUnwrap<Promise<Promise<boolean>>>; // booleanExtract Array Element
infer E captures the element type of an array. For tuples, infer can capture specific positions. Useful for working with generic collections.
type ElementOf<T> = T extends (infer E)[] ? E : never;
type T1 = ElementOf<string[]>; // string
type First<T extends any[]> = T extends [infer F, ...any[]] ? F : never;
type F = First<[string, number]>; // stringExtract Function Parameters
infer P captures the parameter tuple of a function. Parameters is the built-in equivalent. Useful for wrapping functions while preserving types.
type Params<T> = T extends (...args: infer P) => any ? P : never;
function greet(name: string, age: number) { return ''; }
type P = Params<typeof greet>; // [string, number]Multiple infer
Multiple infer variables can capture different parts of a type simultaneously. Enables complex type transformations in a single conditional.
type FirstLast<T extends any[]> =
T extends [infer First, ...any[], infer Last]
? { first: First; last: Last } : never;
type R = FirstLast<[1, 2, 3, 4]>; // { first: 1; last: 4 }Variance
Covariance
Covariance allows Dog[] to be assigned to Animal[]. TypeScript arrays are covariant but this is unsound: pushing an Animal into a Dog[] corrupts the array.
class Animal { name: string; }
class Dog extends Animal { breed: string; }
let dogs: Dog[] = [new Dog()];
let animals: Animal[] = dogs; // OK (covariant)
// animals.push(new Animal()); // Unsafe at runtimeContravariance
Contravariance means a function accepting Dog can be used where a function accepting Animal is expected. Safe because a Dog handler handles any Animal that is a Dog.
type Handler<T> = (arg: T) => void;
let dogHandler: Handler<Dog> = (d) => console.log(d.breed);
let animalHandler: Handler<Animal> = dogHandler; // OK with strictFunctionTypesBivariance
Method syntax is bivariant. Function property syntax is contravariant with strictFunctionTypes. Methods are bivariant for OO compatibility.
interface IFace {
method(x: Animal): void; // bivariant
fn: (x: Animal) => void; // contravariant
}
class Impl implements IFace {
method(x: Dog) {} // OK (bivariant)
fn = (x: Dog) => {} // Error (contravariant)
}in/out Variance
TypeScript 4.7+ supports explicit variance annotations. in marks contravariant (consumers), out marks covariant (producers), in out marks invariant (both).
interface Producer<out T> { produce(): T; }
interface Consumer<in T> { consume(value: T): void; }
interface Buffer<in out T> { read(): T; write(value: T): void; }Invariant Types
Invariant types require exact type matches. A type is invariant when it appears in both input and output positions. Box<Dog> cannot be assigned to Box<Animal>.
interface Box<T> { get(): T; set(value: T): void; }
let dogBox: Box<Dog> = {} as any;
let animalBox: Box<Animal> = dogBox; // Error: invariantBuilder Pattern
Fluent Builder
The builder pattern constructs complex objects step by step. Each method returns this for chaining. Useful for SQL queries, HTTP requests, and configuration.
class QueryBuilder {
private parts: string[] = [];
select(cols: string): this { this.parts.push(`SELECT ${cols}`); return this; }
from(table: string): this { this.parts.push(`FROM ${table}`); return this; }
where(cond: string): this { this.parts.push(`WHERE ${cond}`); return this; }
build(): string { return this.parts.join(' '); }
}Type-Safe Builder
Type-safe builders use conditional types to enforce required fields. build() only returns Person when hasName is true. Catches missing fields at compile time.
interface State { hasName: boolean; }
class Builder<S extends State> {
name(n: string): Builder<S & { hasName: true }> { return this as any; }
build(): S extends { hasName: true } ? Person : never { return {} as any; }
}Immutable Builder
Immutable builders create a new instance for each modification. The type system tracks all added keys through intersection types. Each set returns a new builder type.
class ImmutableBuilder<T extends object> {
constructor(private data: T) {}
set<K extends string, V>(key: K, value: V):
ImmutableBuilder<T & { [P in K]: V }> {
return new ImmutableBuilder({ ...this.data, [key]: value });
}
build(): T { return this.data; }
}Director Pattern
The Director encapsulates common construction sequences. It uses a builder to create standard products. Different directors produce different variations.
class HTMLBuilder {
private html = '';
addTag(tag: string, content: string): this {
this.html += `<${tag}>${content}</${tag}>`; return this;
}
build(): string { return this.html; }
}
class Director {
buildPage(title: string, body: string): string {
return new HTMLBuilder().addTag('title', title).addTag('body', body).build();
}
}Step Builder
Step builder enforces a specific order of method calls through the type system. Each step returns a different type with only the next method available.
class Step1 { name(n: string): Step2 { return new Step2(n); } }
class Step2 {
constructor(private name: string) {}
age(a: number): Step3 { return new Step3(this.name, a); }
}
class Step3 {
constructor(private name: string, private age: number) {}
build() { return { name: this.name, age: this.age }; }
}Testing with TypeScript
Jest with TypeScript
Use ts-jest or @swc/jest for TypeScript tests. describe groups related tests, it defines test cases. expect creates assertions with matchers like toBe, toEqual.
import { sum } from './sum';
describe('sum', () => {
it('adds two numbers', () => {
expect(sum(1, 2)).toBe(3);
});
it('handles negatives', () => {
expect(sum(-1, -2)).toBe(-3);
});
});Type Testing
expectTypeOf tests types at compile time. Verifies return types, parameter types, and resolved promise types. Fails the build if types are wrong.
import { expectTypeOf } from 'vitest';
type GetUser = (id: string) => Promise<{ name: string }>;
test('return type is correct', () => {
const fn = {} as unknown as GetUser;
expectTypeOf(fn).returns.toEqualTypeOf<{ name: string }>();
expectTypeOf(fn).parameters.toEqualTypeOf<[string]>();
});Mocking with Types
jest.Mocked<T> creates a typed mock from an interface. jest.fn() creates mock functions with typed return values. The mock is fully typed.
interface Database { find(id: string): Promise<User>; }
const mockDb: jest.Mocked<Database> = {
find: jest.fn().mockResolvedValue({ id: '1', name: 'Alice' }),
};
mockDb.find.mockResolvedValueOnce({ id: '2', name: 'Bob' });Test Setup & Teardown
beforeAll runs once before all tests, afterAll once after. beforeEach runs before each test, afterEach after each. Use for setup and cleanup.
describe('Database', () => {
beforeAll(async () => { db = createDatabase(); await db.connect(); });
afterAll(async () => { await db.disconnect(); });
beforeEach(async () => { await db.clear(); });
afterEach(() => { jest.restoreAllMocks(); });
});Property-Based Testing
Property-based testing generates random inputs to test invariants. fc.assert runs the property multiple times. Catches edge cases that example-based tests miss.
import { fc } from 'fast-check';
describe('sort', () => {
it('preserves length', () => {
fc.assert(fc.property(fc.array(fc.integer()), (arr) => {
return [...arr].sort().length === arr.length;
}));
});
});Common Pitfalls
any vs unknown
any disables type checking, hiding bugs. unknown is type-safe: you must narrow it before use. Use unknown for untrusted sources (API, JSON.parse).
// BAD: any disables type checking
function bad(data: any) { return data.foo.bar; }
// GOOD: unknown forces type checking
function good(data: unknown) {
if (typeof data === 'object' && data !== null && 'foo' in data)
return (data as any).foo;
}Excess Property Checks
TypeScript only checks excess properties on object literals assigned directly. Via variable, the check is skipped. Use zod for stricter runtime validation.
interface User { name: string; age: number; }
// Direct literal: checked
const u1: User = { name: 'A', age: 30, extra: true }; // Error
// Via variable: not checked
const data = { name: 'A', age: 30, extra: true };
const u2: User = data; // OKEnum vs Union
Enums create runtime objects with reverse mapping. Union types are zero-runtime and tree-shakeable. Prefer union types for new code.
// Enum: runtime object
enum Color { Red, Green, Blue }
// Union: no runtime
type Color2 = 'red' | 'green' | 'blue';
// Const enum: erased
const enum Dir { Up, Down }Structural Typing
TypeScript uses structural typing: types are compatible if shapes match. Admin is assignable to User. This can cause logical bugs. Branded types add nominal distinction.
interface User { name: string; age: number; }
interface Admin { name: string; age: number; role: string; }
const admin: Admin = { name: 'Bob', age: 40, role: 'admin' };
const user: User = admin; // OK (structural)Type Assertion Dangers
Type assertions (as) override TypeScript without runtime checks. Use runtime validation (zod, io-ts) for external data. safeParse returns a result without throwing.
// BAD: assertion hides errors
const user = JSON.parse(input) as User;
// GOOD: runtime validation
import { z } from 'zod';
const schema = z.object({ name: z.string() });
const result = schema.safeParse(JSON.parse(input));
if (result.success) { const user: User = result.data; }Related TypeScript snippets
Copy-paste ready code for common tasks.
Generic Functions
Define and use generic functions.
Conditional Types
Select types based on conditions.
Mapped Types
Construct new types from existing ones.
Utility Types
TypeScript built-in utility types.
Type Guards
Custom type guard functions.
Function Overloads
Define function overload signatures.
Decorators
Class and method decorators.
Enum
Numeric, string, and const enums.
Interface Inheritance
Interface inheritance and implementation.
Abstract Classes
Define abstract classes and abstract methods.
Namespaces
Organize code using namespaces.
Module Declarations
Write type declarations for JS libraries.
Declaration Merging
Merge multiple declarations with the same name.
Optional Chaining
Safely access deep properties.
Nullish Coalescing
Use a default value only for null/undefined.
Type Inference
TypeScript automatically infers types.
const Assertions
Narrow types using as const.
satisfies Operator
Type-check while preserving the narrowest type.
infer Keyword
Extract types within conditional types.
Template Literal Types
Construct types based on strings.
Was this helpful?