← All stacks
TS

TypeScript

14 sections · 99 entries

Basic Types

string, number, boolean

syntax
let varName: string = value;
let varName: number = value;
let varName: boolean = value;
example
let username: string = "alice";
let price: number = 29.99;
let isActive: boolean = true;
output
// All three are primitive types inferred automatically when initialized

Note Use lowercase string, number, boolean. The uppercase versions (String, Number, Boolean) are wrapper objects and almost never what you want.

null & undefined

syntax
let varName: null = null;
let varName: undefined = undefined;
let varName: string | null = null;
example
let resetValue: null = null;
let notAssigned: undefined = undefined;

// Practical usage — nullable types
let selectedUserId: string | null = null;
selectedUserId = "usr_482";
output
// selectedUserId can hold either a string or null

Note With strictNullChecks enabled (recommended), null and undefined are NOT assignable to other types unless you explicitly include them in a union. This catches a huge class of runtime bugs.

any

syntax
let varName: any = value;
example
let legacyPayload: any = fetchFromOldApi();
legacyPayload.whatever.you.want; // No error
legacyPayload = 42;
legacyPayload = "now a string";
output
// Compiles without error — type checking is completely disabled

Note any disables ALL type safety for that value. It propagates silently — anything touching an any value also loses type info. Use unknown instead when you genuinely do not know the type.

unknown

syntax
let varName: unknown = value;
example
let rawInput: unknown = JSON.parse(userInput);

// Must narrow before using
if (typeof rawInput === "string") {
  console.log(rawInput.toUpperCase()); // OK
}

// rawInput.toUpperCase(); // Error: Object is of type 'unknown'
output
// Forces you to check the type before accessing properties

Note unknown is the type-safe counterpart to any. You can assign anything to unknown, but you cannot do anything with it until you narrow the type. Prefer unknown over any for incoming external data.

never

syntax
function varName(): never { ... }
example
function throwAppError(msg: string): never {
  throw new Error(msg);
}

// Used for exhaustive checks
type Shape = "circle" | "square";
function getArea(shape: Shape): number {
  switch (shape) {
    case "circle": return Math.PI * 10;
    case "square": return 100;
    default:
      const _exhaustive: never = shape;
      return _exhaustive;
  }
}
output
// never means this code path should be unreachable

Note never represents values that never occur. A function returning never must not return normally (throw or infinite loop). Assigning to never in a default branch ensures you handle every union member — adding a new Shape member causes a compile error.

void

syntax
function varName(): void { ... }
example
function logMessage(msg: string): void {
  console.log(`[LOG]: ${msg}`);
  // no return statement needed
}

// Callback typing
type EventHandler = (eventName: string) => void;
output
// void indicates the function does not return a meaningful value

Note void is not the same as undefined. A void return type means the caller should not use the return value, but the function may technically return undefined. When used in callback types, void allows the implementation to return anything (the return is just ignored).

symbol & bigint

syntax
let varName: symbol = Symbol(description);
let varName: bigint = valueBigInt;
example
const uniqueKey: unique symbol = Symbol("cacheKey");
let regularSym: symbol = Symbol("temp");

let hugeNumber: bigint = 9007199254740993n;
let anotherBig: bigint = BigInt("123456789012345678");
output
// unique symbol creates a distinct type; bigint handles arbitrarily large integers

Note unique symbol can only be used with const declarations and creates a specific subtype of symbol. bigint cannot be mixed with number in arithmetic — you must explicitly convert. bigint requires target ES2020 or later in tsconfig.

Arrays & Tuples

syntax
let arr: Type[] = [...];
let arr: Array<Type> = [...];
let tup: [TypeA, TypeB] = [a, b];
example
let scores: number[] = [95, 87, 72];
let names: Array<string> = ["Alice", "Bob"];

// Tuple — fixed length, positional types
let userRecord: [string, number, boolean] = ["alice", 30, true];

// Named tuple elements (TS 4.0+)
let range: [start: number, end: number] = [0, 100];
output
// userRecord[0] is string, userRecord[1] is number, userRecord[2] is boolean

Note Type[] and Array<Type> are identical. Tuples enforce length and per-position types at compile time, but at runtime they are just arrays — push() can still add elements unless you mark the tuple as readonly.

object & Object Types

syntax
let varName: { key: Type; key2?: Type } = { ... };
let varName: object = { ... };
example
let product: { name: string; price: number; inStock?: boolean } = {
  name: "Keyboard",
  price: 79.99,
};

// The lowercase 'object' type means any non-primitive
let config: object = { debug: true };
// config.debug; // Error — object type has no known properties
output
// Use inline object types or interfaces for known shapes

Note Avoid the bare object type — it only means 'not a primitive' and has no property access. Use Record<string, unknown> for truly open-ended objects. The Object type (uppercase) matches nearly everything and is rarely useful.

Type Annotations

Variable Annotations

syntax
let varName: Type = value;
const varName: Type = value;
example
let orderId: string = "ORD-7821";
const maxRetries: number = 3;
let tags: string[] = ["urgent", "billing"];

// Often unnecessary when the type is obvious
let count = 0; // inferred as number
const label = "total"; // inferred as literal "total"
output
// Explicit annotations override inference when needed

Note Let TypeScript infer when the type is obvious from the initializer. Add explicit annotations when: the inferred type is too wide, you are declaring without initializing, or you want documentation clarity.

Function Parameter Types

syntax
function fn(param: Type, param2: Type): ReturnType { ... }
example
function calculateTotal(
  items: { name: string; price: number }[],
  taxRate: number
): number {
  const subtotal = items.reduce((sum, item) => sum + item.price, 0);
  return subtotal * (1 + taxRate);
}
output
// Parameters MUST be annotated — TypeScript does not infer param types

Note Function parameters are never inferred from usage. Always annotate them. The return type is usually inferred correctly, but annotating it explicitly catches mistakes when the function body changes.

Return Type Annotations

syntax
function fn(): ReturnType { ... }
const fn = (): ReturnType => { ... };
example
function findUser(id: string): User | undefined {
  return userDatabase.get(id);
}

// Arrow function return type
const formatCurrency = (amount: number): string => {
  return `$${amount.toFixed(2)}`;
};

// Async function
async function fetchOrder(id: string): Promise<Order> {
  const resp = await fetch(`/api/orders/${id}`);
  return resp.json();
}
output
// Async functions always return Promise<T>

Note Explicit return types are especially valuable for public API functions, async functions, and functions with multiple return paths. They also speed up type-checking in large projects since the compiler does not need to analyze the function body.

Type Inference

syntax
// No annotation needed — TypeScript figures it out
let varName = value;
example
let customerName = "Kenji"; // string
const port = 8080; // literal type 8080 (const narrows)

let results = [1, 2, 3]; // number[]
let mixed = [1, "two", true]; // (string | number | boolean)[]

// Return type inference
function double(n: number) {
  return n * 2; // inferred return: number
}
output
// const uses literal types; let uses widened types

Note const declarations infer the narrowest (literal) type, while let declarations widen to the base type. This is called 'widening'. Use 'as const' to get literal types with let, or on objects/arrays to make them deeply readonly with literal types.

Type Assertions

syntax
value as Type
<Type>value  // not in JSX files
example
const rawData = JSON.parse(payload) as { userId: string; role: string };

// HTMLElement narrowing
const canvas = document.getElementById("game") as HTMLCanvasElement;
const ctx = canvas.getContext("2d");

// Double assertion for incompatible types (use sparingly)
const weird = ("hello" as unknown) as number;
output
// Assertions override the compiler — they do NOT perform runtime conversion

Note Type assertions are a compile-time escape hatch — no runtime checking or conversion happens. If you assert incorrectly, you get silent bugs. Prefer type guards (typeof, instanceof) when possible. The angle-bracket syntax <Type>value conflicts with JSX — always use 'as Type' in .tsx files.

Function Overloads

syntax
function fn(param: TypeA): ReturnA;
function fn(param: TypeB): ReturnB;
function fn(param: TypeA | TypeB): ReturnA | ReturnB { ... }
example
function parseInput(input: string): string[];
function parseInput(input: number): number[];
function parseInput(input: string | number): string[] | number[] {
  if (typeof input === "string") {
    return input.split(",");
  }
  return Array.from({ length: input }, (_, i) => i);
}

const words = parseInput("a,b,c"); // string[]
const nums = parseInput(5);        // number[]
output
// Caller sees the specific overload, not the implementation signature

Note The implementation signature is not callable directly — only the overload signatures are visible to callers. Order matters: TypeScript picks the first matching overload. Put more specific signatures before general ones.

Interfaces

Interface Declaration

syntax
interface Name {
  property: Type;
  method(param: Type): ReturnType;
}
example
interface Product {
  id: string;
  name: string;
  price: number;
  getDisplayPrice(): string;
}

const laptop: Product = {
  id: "prod_001",
  name: "Developer Laptop",
  price: 1299,
  getDisplayPrice() {
    return `$${this.price.toFixed(2)}`;
  },
};
output
// laptop.getDisplayPrice() → "$1299.00"

Note Interfaces define the shape of objects. They are structurally typed — any object with the right shape satisfies the interface, no explicit 'implements' needed for plain objects.

Optional Properties

syntax
interface Name {
  required: Type;
  optional?: Type;
}
example
interface NotificationConfig {
  title: string;
  body: string;
  icon?: string;
  durationMs?: number;
  onDismiss?: () => void;
}

function showNotification(config: NotificationConfig) {
  const duration = config.durationMs ?? 3000;
  console.log(`Showing: ${config.title} for ${duration}ms`);
}
output
// Optional props have type T | undefined

Note An optional property (prop?) means 'may be missing or undefined'. This is different from prop: Type | undefined, which requires the key to be present. With exactOptionalPropertyTypes in tsconfig, this distinction is enforced even more strictly.

Readonly Properties

syntax
interface Name {
  readonly property: Type;
}
example
interface DatabaseRecord {
  readonly id: string;
  readonly createdAt: Date;
  name: string;
  updatedAt: Date;
}

const record: DatabaseRecord = {
  id: "rec_abc",
  createdAt: new Date(),
  name: "Initial",
  updatedAt: new Date(),
};

record.name = "Updated"; // OK
// record.id = "rec_xyz"; // Error: Cannot assign to 'id' because it is read-only
output
// readonly prevents reassignment at compile time only

Note readonly is a compile-time constraint — there is no runtime enforcement. Deep objects with readonly props can still have their nested properties mutated unless those are also readonly. Use Readonly<T> utility to make all properties readonly at once.

Extending Interfaces

syntax
interface Child extends Parent {
  additionalProp: Type;
}
interface Child extends ParentA, ParentB { ... }
example
interface Timestamped {
  createdAt: Date;
  updatedAt: Date;
}

interface SoftDeletable {
  deletedAt: Date | null;
}

interface BlogPost extends Timestamped, SoftDeletable {
  title: string;
  content: string;
  authorId: string;
}

// BlogPost has: title, content, authorId, createdAt, updatedAt, deletedAt
output
// Multiple inheritance via comma-separated parent interfaces

Note An interface can extend multiple other interfaces. If parent interfaces have conflicting property types, you get a compile error. Interfaces can also extend type aliases (as long as the alias resolves to an object shape).

Implementing Interfaces

syntax
class ClassName implements InterfaceName { ... }
example
interface Logger {
  info(message: string): void;
  error(message: string, context?: Record<string, unknown>): void;
}

class ConsoleLogger implements Logger {
  info(message: string): void {
    console.log(`[INFO] ${message}`);
  }

  error(message: string, context?: Record<string, unknown>): void {
    console.error(`[ERROR] ${message}`, context ?? "");
  }
}
output
// Class must define all properties/methods declared in the interface

Note implements does NOT add types to the class — it only checks that the class satisfies the shape. You must still annotate parameter types in the class methods. A class can implement multiple interfaces with comma separation.

Index Signatures

syntax
interface Name {
  [key: string]: Type;
  [index: number]: Type;
}
example
interface TranslationMap {
  [locale: string]: string;
}

const greetings: TranslationMap = {
  en: "Hello",
  es: "Hola",
  ja: "こんにちは",
};

// Mixed: known + dynamic keys
interface AppConfig {
  appName: string;
  version: string;
  [key: string]: string; // all values must be string
}
output
// greetings["fr"] is string (even if missing at runtime)

Note Index signatures allow any key of the given type. When combined with named properties, all named property types must be compatible with the index signature type. Enable noUncheckedIndexedAccess in tsconfig to get T | undefined instead of just T for indexed access — much safer.

Declaration Merging

syntax
interface Name { propA: Type; }
interface Name { propB: Type; }
// Merged into one interface with both props
example
// Original library type
interface Window {
  title: string;
}

// Your augmentation — merges with the above
interface Window {
  analyticsId: string;
}

// Now Window has both title and analyticsId
const w: Window = {
  title: "My App",
  analyticsId: "UA-12345",
};
output
// Both declarations merge into a single interface

Note Declaration merging is unique to interfaces — type aliases cannot be re-opened. This is powerful for extending third-party types (like adding properties to Window or Express Request). The merged properties must not conflict in type.

Type Aliases

Type Alias Basics

syntax
type Name = Type;
example
type UserId = string;
type Coordinate = { x: number; y: number };
type StringOrNumber = string | number;
type Callback = (data: unknown) => void;

const origin: Coordinate = { x: 0, y: 0 };
const userId: UserId = "usr_9f3a";
output
// Type aliases create a name for any type expression

Note Type aliases and interfaces overlap for object shapes, but type aliases can also name unions, tuples, primitives, and function types. Unlike interfaces, type aliases cannot be re-declared for merging. Use interfaces for object shapes that might be extended; use type aliases for everything else.

Union Types

syntax
type Name = TypeA | TypeB | TypeC;
example
type Status = "pending" | "active" | "archived";
type ApiResponse = SuccessResult | ErrorResult;

interface SuccessResult {
  ok: true;
  data: unknown;
}
interface ErrorResult {
  ok: false;
  errorCode: number;
  message: string;
}

function handleResponse(res: ApiResponse) {
  if (res.ok) {
    console.log(res.data); // narrowed to SuccessResult
  } else {
    console.error(res.message); // narrowed to ErrorResult
  }
}
output
// Union means 'one of these types' — must narrow before accessing specific members

Note You can only access properties common to ALL members of a union without narrowing first. Use a shared discriminant property (like 'ok' above) to let TypeScript narrow automatically in conditionals.

Intersection Types

syntax
type Name = TypeA & TypeB;
example
type WithId = { id: string };
type WithTimestamps = { createdAt: Date; updatedAt: Date };

type UserRecord = WithId & WithTimestamps & {
  email: string;
  displayName: string;
};

const user: UserRecord = {
  id: "usr_001",
  email: "[email protected]",
  displayName: "Dev User",
  createdAt: new Date(),
  updatedAt: new Date(),
};
output
// Intersection combines all properties from all types

Note Intersection means 'all of these combined'. If two types have the same property with incompatible types, the intersected property becomes never (which usually means a mistake). Intersections are great for mixin-style composition.

Literal Types

syntax
type Name = "exactValue" | "anotherValue";
type Name = 0 | 1 | 2;
example
type Direction = "north" | "south" | "east" | "west";
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
type DiceRoll = 1 | 2 | 3 | 4 | 5 | 6;

function move(direction: Direction, steps: number) {
  console.log(`Moving ${direction} by ${steps}`);
}

move("north", 3); // OK
// move("up", 3); // Error: Argument of type '"up"' is not assignable
output
// Literal types restrict a value to an exact set of allowed values

Note Literal types turn strings, numbers, or booleans into specific-value types. Combined with unions, they create powerful enumerations without the overhead of enum. const assertions (as const) automatically infer literal types.

Template Literal Types

syntax
type Name = `prefix${OtherType}suffix`;
example
type EventName = "click" | "scroll" | "resize";
type EventHandler = `on${Capitalize<EventName>}`;
// Result: "onClick" | "onScroll" | "onResize"

type CssUnit = "px" | "em" | "rem" | "%";
type CssValue = `${number}${CssUnit}`;

const width: CssValue = "100px"; // OK
const padding: CssValue = "1.5rem"; // OK
// const bad: CssValue = "big"; // Error
output
// EventHandler = "onClick" | "onScroll" | "onResize"

Note Template literal types construct new string types from combinations. They distribute over unions — every combination is produced. Built-in string manipulation types (Capitalize, Uppercase, Lowercase, Uncapitalize) work inside template literals. Extremely powerful for typing DSLs and API patterns.

Generic Type Aliases

syntax
type Name<T> = { ... };
type Name<T, U = DefaultType> = ...;
example
type ApiResult<T> = {
  data: T;
  status: number;
  timestamp: Date;
};

type Nullable<T> = T | null;
type Pair<A, B = A> = [A, B];

const userResult: ApiResult<{ name: string }> = {
  data: { name: "Priya" },
  status: 200,
  timestamp: new Date(),
};

const point: Pair<number> = [10, 20]; // both number
const labeled: Pair<string, number> = ["x", 42];
output
// Generic aliases accept type parameters just like functions accept value parameters

Note Generic type aliases are essential for building reusable type utilities. Default type parameters (B = A) make common cases convenient while keeping the alias flexible. You can also add constraints with extends.

Generics

Generic Functions

syntax
function fn<T>(param: T): T { ... }
const fn = <T>(param: T): T => { ... };
example
function firstElement<T>(items: T[]): T | undefined {
  return items[0];
}

const topScore = firstElement([95, 87, 72]); // number | undefined
const firstName = firstElement(["Alice", "Bob"]); // string | undefined

// Multiple type parameters
function mapEntry<K, V>(key: K, value: V): [K, V] {
  return [key, value];
}
const entry = mapEntry("age", 30); // [string, number]
output
// TypeScript infers T from the argument — no need to specify it manually

Note Type parameters are inferred from arguments in most cases. Only specify them explicitly when inference gives the wrong type. In .tsx files, arrow generics need a trailing comma: <T,>(param: T) to avoid JSX ambiguity.

Generic Interfaces

syntax
interface Name<T> {
  property: T;
  method(param: T): void;
}
example
interface Repository<T> {
  findById(id: string): Promise<T | null>;
  findAll(): Promise<T[]>;
  save(entity: T): Promise<T>;
  delete(id: string): Promise<boolean>;
}

interface User {
  id: string;
  email: string;
}

class UserRepository implements Repository<User> {
  async findById(id: string): Promise<User | null> {
    // implementation
    return null;
  }
  async findAll(): Promise<User[]> { return []; }
  async save(entity: User): Promise<User> { return entity; }
  async delete(id: string): Promise<boolean> { return true; }
}
output
// Repository<User> locks T to User for the entire implementation

Note Generic interfaces are ideal for defining contracts that work across multiple entity types — repositories, services, collections. Each implementation locks in the concrete type.

Generic Classes

syntax
class Name<T> {
  constructor(private value: T) {}
}
example
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 numberStack = new Stack<number>();
numberStack.push(10);
numberStack.push(20);
const top = numberStack.pop(); // number | undefined
output
// top → 20

Note Static members of a generic class cannot reference the class type parameter — the parameter only exists on instances. If you need generic static methods, make the method itself generic rather than the class.

Generic Constraints

syntax
function fn<T extends ConstraintType>(param: T): T { ... }
example
interface HasLength {
  length: number;
}

function logLength<T extends HasLength>(item: T): T {
  console.log(`Length: ${item.length}`);
  return item;
}

logLength("hello");        // string has .length
logLength([1, 2, 3]);      // arrays have .length
// logLength(42);           // Error: number has no .length

// Constrain to object keys
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const color = getProperty({ r: 255, g: 128, b: 0 }, "g"); // number
output
// Constraints restrict what types a generic can accept

Note Use extends to set a minimum requirement for a type parameter. The constraint keyof T is extremely useful — it restricts a parameter to valid property names of another type, enabling fully type-safe property access.

Default Type Parameters

syntax
function fn<T = DefaultType>(param: T): T { ... }
interface Name<T = DefaultType> { ... }
example
interface ApiResponse<TData = unknown, TError = string> {
  success: boolean;
  data?: TData;
  error?: TError;
}

// Uses defaults
const basic: ApiResponse = { success: true };

// Overrides first, keeps second default
const typed: ApiResponse<{ users: string[] }> = {
  success: true,
  data: { users: ["alice"] },
};

// Overrides both
const custom: ApiResponse<null, { code: number; msg: string }> = {
  success: false,
  error: { code: 404, msg: "Not found" },
};
output
// Default types make generics more ergonomic for common cases

Note Default type parameters work like default function arguments — they must come after required type parameters. A default does not constrain the type; add extends if you also need a constraint: <T extends object = Record<string, unknown>>.

Building Utilities with Generics

syntax
type UtilityName<T> = { [K in keyof T]: TransformedType };
example
// Make all properties optional and nullable
type Draft<T> = {
  [K in keyof T]?: T[K] | null;
};

interface Article {
  title: string;
  body: string;
  publishedAt: Date;
}

type ArticleDraft = Draft<Article>;
// { title?: string | null; body?: string | null; publishedAt?: Date | null }

const draft: ArticleDraft = {
  title: "Work in progress",
};
output
// Custom utility types combine generics with mapped types

Note This pattern (mapped types + generics) is how TypeScript's built-in utility types (Partial, Required, Readonly) are implemented. Understanding this lets you build project-specific utilities tailored to your domain.

Generics with Conditional Types

syntax
type Name<T> = T extends Condition ? TrueType : FalseType;
example
type IsArray<T> = T extends any[] ? true : false;

type Test1 = IsArray<string[]>;  // true
type Test2 = IsArray<number>;    // false

// Extracting element types
type ElementOf<T> = T extends (infer E)[] ? E : never;

type StrEl = ElementOf<string[]>;      // string
type NumEl = ElementOf<number[]>;      // number
type NoEl = ElementOf<boolean>;        // never
output
// Conditional types act like type-level if/else

Note When a conditional type receives a union as T, it distributes — the condition is applied to each union member independently. To prevent distribution, wrap both sides in brackets: [T] extends [Condition].

Utility Types

Partial<T>

syntax
type Result = Partial<OriginalType>;
example
interface Settings {
  theme: "light" | "dark";
  fontSize: number;
  notifications: boolean;
}

function updateSettings(current: Settings, changes: Partial<Settings>): Settings {
  return { ...current, ...changes };
}

const updated = updateSettings(
  { theme: "light", fontSize: 14, notifications: true },
  { fontSize: 16 } // only need to pass what changed
);
output
// Partial<Settings> makes all properties optional

Note Partial only operates one level deep. Nested objects keep their original required types. For deep partial, you need a custom recursive type or a library utility.

Required<T>

syntax
type Result = Required<OriginalType>;
example
interface FormFields {
  username?: string;
  email?: string;
  password?: string;
}

// After validation, all fields must be present
type ValidatedForm = Required<FormFields>;

const validated: ValidatedForm = {
  username: "alice",
  email: "[email protected]",
  password: "s3cureP@ss",
};
// Missing any field → compile error
output
// Required<T> removes all ? optional markers

Note Required is the inverse of Partial. Like Partial, it only works one level deep. Useful for representing the 'after-validation' state where you know all optional fields have been filled in.

Readonly<T>

syntax
type Result = Readonly<OriginalType>;
example
interface AppState {
  user: { name: string; role: string };
  items: string[];
  isLoading: boolean;
}

function freezeState(state: AppState): Readonly<AppState> {
  return Object.freeze(state);
}

const frozen = freezeState({ user: { name: "A", role: "admin" }, items: [], isLoading: false });
// frozen.isLoading = true; // Error: Cannot assign to 'isLoading'
output
// Readonly<T> marks all top-level properties as readonly

Note Readonly only applies to the top level. frozen.user.name = 'B' would still compile because the nested object is not frozen. For deep immutability, use 'as const' on literals, or build a DeepReadonly recursive type.

Pick<T, Keys>

syntax
type Result = Pick<OriginalType, "key1" | "key2">;
example
interface Employee {
  id: string;
  name: string;
  email: string;
  department: string;
  salary: number;
}

type EmployeePreview = Pick<Employee, "id" | "name" | "department">;

const preview: EmployeePreview = {
  id: "emp_042",
  name: "Jordan",
  department: "Engineering",
};
output
// EmployeePreview has only id, name, and department

Note Pick creates a new type with only the specified keys. The keys must actually exist on T — typos cause compile errors. Pick is great for API response shapes where you only need a subset of fields.

Omit<T, Keys>

syntax
type Result = Omit<OriginalType, "key1" | "key2">;
example
interface BlogPost {
  id: string;
  title: string;
  body: string;
  authorId: string;
  createdAt: Date;
}

type CreatePostInput = Omit<BlogPost, "id" | "createdAt">;

const newPost: CreatePostInput = {
  title: "Getting Started with TypeScript",
  body: "TypeScript adds type safety to JavaScript...",
  authorId: "usr_007",
};
output
// CreatePostInput has title, body, authorId (no id or createdAt)

Note Omit does NOT enforce that the keys exist on T — you can omit keys that are not present without error. This is a known gotcha; misspelled keys silently pass. Use a custom StrictOmit if you need safety.

Record<Keys, Value>

syntax
type Result = Record<KeyType, ValueType>;
example
type Role = "admin" | "editor" | "viewer";

interface Permission {
  canRead: boolean;
  canWrite: boolean;
  canDelete: boolean;
}

const rolePermissions: Record<Role, Permission> = {
  admin:  { canRead: true,  canWrite: true,  canDelete: true },
  editor: { canRead: true,  canWrite: true,  canDelete: false },
  viewer: { canRead: true,  canWrite: false, canDelete: false },
};
output
// Record forces every Role to have a Permission entry

Note When Keys is a union of string literals, Record ensures every literal is present — great for exhaustive mappings. Record<string, T> is equivalent to { [key: string]: T } and does not enforce specific keys.

Exclude & Extract

syntax
type Result = Exclude<UnionType, ExcludedMembers>;
type Result = Extract<UnionType, ExtractedMembers>;
example
type AllEvents = "click" | "scroll" | "keydown" | "keyup" | "resize";

type KeyboardEvents = Extract<AllEvents, "keydown" | "keyup">;
// "keydown" | "keyup"

type NonKeyboardEvents = Exclude<AllEvents, "keydown" | "keyup">;
// "click" | "scroll" | "resize"

// Works with complex types too
type OnlyStrings = Extract<string | number | boolean, string>;
// string
output
// Exclude removes members; Extract keeps matching members

Note These work on union members, not object properties. To remove properties from an object, use Omit. Exclude and Extract filter which union members match the condition using distributive conditional types under the hood.

NonNullable<T>

syntax
type Result = NonNullable<Type>;
example
type MaybeUser = string | null | undefined;
type DefiniteUser = NonNullable<MaybeUser>;
// string

function processValue(input: string | null | undefined) {
  // After validation
  const safe: NonNullable<typeof input> = input!;
  console.log(safe.toUpperCase());
}
output
// NonNullable removes null and undefined from a union

Note NonNullable is shorthand for Exclude<T, null | undefined>. It is commonly used after runtime null checks to assert the cleaned-up type. Pairs well with strictNullChecks.

ReturnType & Parameters

syntax
type Ret = ReturnType<typeof fn>;
type Params = Parameters<typeof fn>;
example
function createOrder(userId: string, items: string[], coupon?: string) {
  return {
    orderId: `ord_${Date.now()}`,
    userId,
    items,
    discount: coupon ? 0.1 : 0,
  };
}

type OrderResult = ReturnType<typeof createOrder>;
// { orderId: string; userId: string; items: string[]; discount: number }

type OrderParams = Parameters<typeof createOrder>;
// [userId: string, items: string[], coupon?: string]
output
// ReturnType extracts what the function returns; Parameters extracts the argument tuple

Note Both require typeof when used with a concrete function (not a type). For class constructors, use ConstructorParameters<typeof ClassName>. ReturnType is invaluable for inferring types from existing functions without duplicating definitions.

Awaited<T>

syntax
type Result = Awaited<PromiseType>;
example
type PromisedUser = Promise<{ id: string; name: string }>;
type ResolvedUser = Awaited<PromisedUser>;
// { id: string; name: string }

// Handles nested promises
type DeepPromise = Promise<Promise<Promise<number>>>;
type DeepResolved = Awaited<DeepPromise>;
// number

// Practical: extract resolved type from async function
async function loadConfig() {
  return { apiUrl: "https://api.example.com", timeout: 5000 };
}
type Config = Awaited<ReturnType<typeof loadConfig>>;
output
// Awaited recursively unwraps all layers of Promise

Note Added in TS 4.5. Awaited recursively unwraps nested Promises until it hits a non-Promise type. Before Awaited existed, developers had to write custom recursive unwrap types. Works with any thenable, not just native Promises.

Enums

Numeric Enums

syntax
enum Name {
  Member = value,
  AutoIncremented,
}
example
enum HttpStatus {
  Ok = 200,
  Created = 201,
  BadRequest = 400,
  Unauthorized = 401,
  NotFound = 404,
  ServerError = 500,
}

function isSuccessful(status: HttpStatus): boolean {
  return status >= 200 && status < 300;
}

console.log(isSuccessful(HttpStatus.Ok)); // true
console.log(HttpStatus.NotFound);         // 404
output
// true
// 404

Note Numeric enums auto-increment from the last explicit value. If no values are assigned, they start at 0. Beware: TypeScript allows assigning ANY number to a numeric enum variable, not just declared members — this is a known type safety gap.

String Enums

syntax
enum Name {
  Member = "value",
}
example
enum LogLevel {
  Debug = "DEBUG",
  Info = "INFO",
  Warn = "WARN",
  Error = "ERROR",
  Fatal = "FATAL",
}

function log(level: LogLevel, message: string) {
  console.log(`[${level}] ${message}`);
}

log(LogLevel.Error, "Database connection lost");
// log("ERROR", "..."); // Error: '"ERROR"' is not assignable to 'LogLevel'
output
// [ERROR] Database connection lost

Note String enums do NOT auto-increment — every member needs an explicit string value. They are more type-safe than numeric enums because you cannot accidentally pass an arbitrary string. The downside: you cannot pass the raw string value, only the enum member.

const Enums

syntax
const enum Name {
  Member = value,
}
example
const enum Direction {
  Up = "UP",
  Down = "DOWN",
  Left = "LEFT",
  Right = "RIGHT",
}

const playerDirection = Direction.Up;
// Compiles to: const playerDirection = "UP";
// No Direction object exists at runtime
output
// const enums are completely erased — values are inlined at compile time

Note const enums produce zero runtime JavaScript — member accesses are replaced with literal values. However, they have compatibility issues: they cannot be used with --isolatedModules (Babel, esbuild, SWC), and they do not work across declaration files in some setups. Many teams ban them in favor of plain unions.

Enum Members as Types

syntax
function fn(param: EnumName.Member): void { ... }
example
enum Permission {
  Read = "READ",
  Write = "WRITE",
  Admin = "ADMIN",
}

// Each member is its own type
function grantAdmin(userId: string, level: Permission.Admin) {
  console.log(`Granting admin to ${userId}`);
}

grantAdmin("usr_1", Permission.Admin); // OK
// grantAdmin("usr_1", Permission.Read); // Error: not assignable to Permission.Admin
output
// Individual enum members can be used as specific types

Note Each enum member is a subtype of the enum. You can use individual members in type positions to restrict parameters to a specific enum value. This is more useful with string enums since numeric enum members have the wider number type.

Reverse Mapping (Numeric Enums)

syntax
EnumName[numericValue] // returns the member name as string
example
enum Priority {
  Low = 0,
  Medium = 1,
  High = 2,
  Critical = 3,
}

const level = Priority.High;
console.log(level);             // 2
console.log(Priority[level]);   // "High"

// Practical: converting API response to label
function getPriorityLabel(code: number): string {
  return Priority[code] ?? "Unknown";
}
output
// 2
// "High"

Note Reverse mapping ONLY works with numeric enums — string enums do not generate reverse mappings. The compiled JavaScript includes both name→value and value→name entries in the enum object. const enums do not support reverse mapping since they have no runtime object.

Union Types as Enum Alternatives

syntax
type Name = "value1" | "value2" | "value3";
example
// Instead of enum:
type Theme = "light" | "dark" | "system";

// Object const pattern for when you need both runtime values and types
const THEMES = {
  Light: "light",
  Dark: "dark",
  System: "system",
} as const;

type Theme2 = (typeof THEMES)[keyof typeof THEMES];
// "light" | "dark" | "system"

function applyTheme(theme: Theme2) {
  document.body.dataset.theme = theme;
}
output
// Union types achieve the same result without enum's quirks

Note Many TypeScript teams prefer string literal unions over enums for simplicity, full tree-shaking, and compatibility with --isolatedModules. The 'as const' object pattern gives you both runtime values (for iteration/lookup) and a derived type — the best of both worlds.

Type Guards & Narrowing

typeof Guard

syntax
if (typeof value === "string") { ... }
example
function formatValue(input: string | number | boolean): string {
  if (typeof input === "string") {
    return input.toUpperCase();
  }
  if (typeof input === "number") {
    return input.toFixed(2);
  }
  return input ? "Yes" : "No";
}
output
// TypeScript narrows the type inside each branch automatically

Note typeof works for: "string", "number", "boolean", "undefined", "symbol", "bigint", "function", "object". Note: typeof null === "object" — this is a famous JavaScript quirk that TypeScript inherits.

instanceof Guard

syntax
if (value instanceof ClassName) { ... }
example
class ApiError extends Error {
  constructor(public statusCode: number, message: string) {
    super(message);
  }
}

class ValidationError extends Error {
  constructor(public fields: string[], message: string) {
    super(message);
  }
}

function handleError(err: Error) {
  if (err instanceof ApiError) {
    console.log(`API error ${err.statusCode}: ${err.message}`);
  } else if (err instanceof ValidationError) {
    console.log(`Invalid fields: ${err.fields.join(", ")}`);
  } else {
    console.log(`Unknown error: ${err.message}`);
  }
}
output
// instanceof narrows to the specific class, enabling access to class-specific properties

Note instanceof checks the prototype chain at runtime. It does not work with interfaces or type aliases (they have no runtime presence). It also fails across different realms (iframes) since each realm has its own constructor.

in Operator Guard

syntax
if ("property" in value) { ... }
example
interface EmailContact {
  email: string;
  name: string;
}

interface PhoneContact {
  phone: string;
  name: string;
}

function sendMessage(contact: EmailContact | PhoneContact) {
  if ("email" in contact) {
    console.log(`Emailing ${contact.email}`);
  } else {
    console.log(`Calling ${contact.phone}`);
  }
}
output
// 'in' narrows based on the presence of a property

Note The 'in' operator checks if a property name exists on an object at runtime. TypeScript uses this to narrow union types. The property name must be a string literal for narrowing to work. Works well when union members have distinct unique properties.

Custom Type Guard Functions

syntax
function isType(value: ParamType): value is TargetType {
  return /* boolean check */;
}
example
interface Fish {
  swim: () => void;
  habitat: "water";
}

interface Bird {
  fly: () => void;
  habitat: "air";
}

function isFish(creature: Fish | Bird): creature is Fish {
  return creature.habitat === "water";
}

function move(creature: Fish | Bird) {
  if (isFish(creature)) {
    creature.swim(); // TypeScript knows this is Fish
  } else {
    creature.fly();  // TypeScript knows this is Bird
  }
}
output
// Custom predicates with 'is' return type enable reusable narrowing

Note The 'value is Type' return annotation is a type predicate — it tells TypeScript to narrow the variable when the function returns true. The compiler trusts your implementation; if your check is wrong, you get silent type errors at runtime. Always keep the check accurate.

Discriminated Unions

syntax
interface A { kind: "a"; ... }
interface B { kind: "b"; ... }
type Union = A | B;
example
interface LoadingState {
  status: "loading";
}

interface SuccessState {
  status: "success";
  data: string[];
}

interface ErrorState {
  status: "error";
  errorMessage: string;
}

type RequestState = LoadingState | SuccessState | ErrorState;

function renderState(state: RequestState): string {
  switch (state.status) {
    case "loading":
      return "Loading...";
    case "success":
      return `Got ${state.data.length} items`; // data is available
    case "error":
      return `Error: ${state.errorMessage}`; // errorMessage is available
  }
}
output
// Each case automatically narrows to the correct interface

Note A discriminated union has a common literal-typed property (the 'discriminant') shared across all members. TypeScript narrows exhaustively in switch/if on that property. This is the recommended pattern for modeling states, events, and message types.

Assertion Functions

syntax
function assert(value: unknown): asserts value is Type {
  if (!check) throw new Error(...);
}
example
function assertIsString(value: unknown): asserts value is string {
  if (typeof value !== "string") {
    throw new TypeError(`Expected string, got ${typeof value}`);
  }
}

function assertDefined<T>(value: T | null | undefined): asserts value is T {
  if (value == null) {
    throw new Error("Value must not be null or undefined");
  }
}

function processInput(data: unknown) {
  assertIsString(data);
  // After this line, data is typed as string
  console.log(data.toUpperCase());
}
output
// After the assertion call, TypeScript narrows for the rest of the scope

Note Assertion functions narrow by throwing on failure rather than returning a boolean. They use 'asserts value is Type' (or just 'asserts value' for truthiness). Unlike type predicates, they affect the current scope rather than an if branch. Must throw — not return false.

Truthiness Narrowing

syntax
if (value) { ... } // narrows out null, undefined, 0, "", false
example
function printLength(input: string | null | undefined) {
  if (input) {
    // input is narrowed to string (null and undefined excluded)
    console.log(`Length: ${input.length}`);
  } else {
    console.log("No input provided");
  }
}

// Combining with logical operators
function getDisplayName(first?: string, last?: string): string {
  return (first && last) ? `${first} ${last}` : first ?? last ?? "Anonymous";
}
output
// Truthiness checks exclude null, undefined, and falsy values

Note Truthiness narrowing also excludes 0, empty string, and false — which may not be what you want. If 0 or "" are valid values, check explicitly for null/undefined instead: if (value != null) or if (value !== undefined).

Advanced Types

Conditional Types

syntax
type Result = T extends Condition ? TrueType : FalseType;
example
type IsString<T> = T extends string ? "yes" : "no";

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

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

type Str = Flatten<string[]>;   // string
type Num = Flatten<number>;     // number (not an array, returned as-is)
output
// Conditional types branch on whether T matches a shape

Note Conditional types distribute over naked union type parameters: IsString<string | number> becomes IsString<string> | IsString<number> = "yes" | "no". Wrap in tuple to prevent distribution: [T] extends [string] ? ... : ...

Mapped Types

syntax
type Result = {
  [K in keyof T]: NewType;
};
example
interface UserProfile {
  name: string;
  email: string;
  age: number;
}

// Make every property a getter function
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

type UserGetters = Getters<UserProfile>;
// {
//   getName: () => string;
//   getEmail: () => string;
//   getAge: () => number;
// }

// Make all properties mutable (remove readonly)
type Mutable<T> = {
  -readonly [K in keyof T]: T[K];
};
output
// Mapped types transform every property of an existing type

Note Key remapping with 'as' (TS 4.1+) enables renaming keys. Filter out keys by mapping to never: [K in keyof T as T[K] extends Function ? never : K]. The +/- modifiers add or remove readonly and optional (?) markers.

infer Keyword

syntax
type Result = T extends Pattern<infer U> ? U : Fallback;
example
// Extract the resolved type from a Promise
type UnwrapPromise<T> = T extends Promise<infer R> ? R : T;

type A = UnwrapPromise<Promise<string>>;  // string
type B = UnwrapPromise<number>;           // number

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

type FA = FirstArg<(name: string, age: number) => void>;  // string

// Extract array element type
type ArrayItem<T> = T extends readonly (infer E)[] ? E : never;
type Item = ArrayItem<readonly ["a", "b", "c"]>;  // "a" | "b" | "c"
output
// infer declares a type variable that TypeScript figures out from context

Note infer can only be used inside the extends clause of a conditional type. It captures whatever type fits in that position. Multiple infer clauses in the same conditional are allowed. In union position, infer produces a union; in intersection position (like function params), it produces an intersection.

Recursive Types

syntax
type TreeNode<T> = {
  value: T;
  children: TreeNode<T>[];
};
example
// JSON-compatible type
type JsonValue =
  | string
  | number
  | boolean
  | null
  | JsonValue[]
  | { [key: string]: JsonValue };

// Deep Readonly
type DeepReadonly<T> = T extends object
  ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
  : T;

interface NestedConfig {
  db: { host: string; port: number; ssl: { enabled: boolean } };
}

type FrozenConfig = DeepReadonly<NestedConfig>;
// All levels are readonly — db.ssl.enabled cannot be reassigned
output
// Recursive types reference themselves in their definition

Note TypeScript handles direct recursive type aliases since TS 3.7. Earlier versions needed interface workarounds. Be careful with deeply recursive conditional types — the compiler has a recursion depth limit (around 50 levels by default) and will error if exceeded.

Branded / Nominal Types

syntax
type Brand<T, B> = T & { readonly __brand: B };
example
type USD = number & { readonly __brand: "USD" };
type EUR = number & { readonly __brand: "EUR" };

function usd(amount: number): USD {
  return amount as USD;
}

function eur(amount: number): EUR {
  return amount as EUR;
}

function chargeUSD(amount: USD) {
  console.log(`Charging $${amount}`);
}

const price = usd(29.99);
chargeUSD(price);          // OK
// chargeUSD(eur(25.00));  // Error: EUR is not assignable to USD
// chargeUSD(29.99);       // Error: number is not assignable to USD
output
// Branded types prevent accidentally mixing structurally identical types

Note TypeScript uses structural typing — two types with the same shape are interchangeable. Branded types add a phantom property to create nominal (name-based) distinction. The __brand property never exists at runtime; it is purely a compile-time discriminator. Use this for user IDs, currency, coordinates, etc.

Advanced Template Literal Types

syntax
type Result = `${TypeA}${TypeB}`;
example
type HTTPMethod = "GET" | "POST" | "PUT" | "DELETE";
type APIPath = "/users" | "/orders" | "/products";

// Generate all route strings
type APIRoute = `${HTTPMethod} ${APIPath}`;
// "GET /users" | "GET /orders" | "GET /products" | "POST /users" | ... (12 total)

// Extract path params
type ExtractParams<T extends string> =
  T extends `${string}:${infer Param}/${infer Rest}`
    ? Param | ExtractParams<Rest>
    : T extends `${string}:${infer Param}`
    ? Param
    : never;

type Params = ExtractParams<"/users/:userId/posts/:postId">;
// "userId" | "postId"
output
// Template literals combined with infer enable string parsing at the type level

Note Template literal types combined with conditional types and infer enable powerful string parsing and transformation at compile time. This is the foundation for type-safe routing, ORM query builders, and CSS-in-JS libraries.

satisfies Operator

syntax
const value = expression satisfies Type;
example
type ColorMap = Record<string, string | number[]>;

// Without satisfies: loses specific types
// const colors: ColorMap = { ... } → all values are string | number[]

// With satisfies: validates shape AND keeps specific types
const colors = {
  red: "#ff0000",
  green: [0, 255, 0],
  blue: "#0000ff",
} satisfies ColorMap;

colors.red.toUpperCase();   // OK — TypeScript knows red is string
colors.green.map(c => c);   // OK — TypeScript knows green is number[]
// colors.red.map(c => c);  // Error — string has no .map
output
// satisfies validates compatibility without widening the inferred type

Note Added in TS 4.9. satisfies checks that a value matches a type without changing the inferred type. This gives you the best of both worlds: validation that the shape is correct, plus precise inference of each property. Use it instead of explicit type annotations when you want both safety and specificity.

keyof & typeof Operators

syntax
type Keys = keyof Type;
type ValueType = typeof runtimeValue;
example
interface Product {
  id: string;
  name: string;
  price: number;
}

type ProductKey = keyof Product; // "id" | "name" | "price"

function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
  return items.map(item => item[key]);
}

// typeof: extract type from a runtime value
const defaultSettings = {
  volume: 50,
  brightness: 80,
  darkMode: true,
};

type Settings = typeof defaultSettings;
// { volume: number; brightness: number; darkMode: boolean }
output
// keyof extracts property names; typeof extracts the type of a value

Note keyof works on types; typeof works on values. They are often combined: keyof typeof myObject gives you the union of property names of a runtime object. typeof only works with variables and properties, not with arbitrary expressions like function calls.

Classes

Access Modifiers

syntax
class Name {
  public prop: Type;
  private prop: Type;
  protected prop: Type;
}
example
class BankAccount {
  public accountHolder: string;
  private balance: number;
  protected accountType: string;

  constructor(holder: string, initial: number) {
    this.accountHolder = holder;
    this.balance = initial;
    this.accountType = "checking";
  }

  public getBalance(): number {
    return this.balance;
  }

  private validateAmount(amount: number): boolean {
    return amount > 0 && amount <= this.balance;
  }
}

const account = new BankAccount("Alice", 1000);
console.log(account.accountHolder); // OK
// account.balance; // Error: private
output
// public = anywhere, private = same class only, protected = class + subclasses

Note TypeScript access modifiers are compile-time only — at runtime, everything is accessible. For true runtime privacy, use JavaScript's native #private fields (e.g., #balance). The public modifier is the default and can be omitted.

Parameter Properties

syntax
class Name {
  constructor(public prop: Type, private prop2: Type) {}
}
example
// Without parameter properties — verbose
class VerboseUser {
  name: string;
  email: string;
  constructor(name: string, email: string) {
    this.name = name;
    this.email = email;
  }
}

// With parameter properties — concise
class User {
  constructor(
    public readonly name: string,
    public email: string,
    private passwordHash: string
  ) {}

  display(): string {
    return `${this.name} <${this.email}>`;
  }
}
output
// Adding an access modifier to a constructor param auto-declares and assigns the property

Note Parameter properties reduce boilerplate by combining declaration, constructor parameter, and assignment into one. You can combine them with readonly. Regular (unmodified) parameters are not turned into properties — only those prefixed with public, private, protected, or readonly.

Abstract Classes

syntax
abstract class Name {
  abstract method(): Type;
  concreteMethod(): Type { ... }
}
example
abstract class Shape {
  abstract area(): number;
  abstract perimeter(): number;

  describe(): string {
    return `Area: ${this.area().toFixed(2)}, Perimeter: ${this.perimeter().toFixed(2)}`;
  }
}

class Circle extends Shape {
  constructor(private radius: number) {
    super();
  }

  area(): number {
    return Math.PI * this.radius ** 2;
  }

  perimeter(): number {
    return 2 * Math.PI * this.radius;
  }
}

// const shape = new Shape(); // Error: Cannot instantiate abstract class
const circle = new Circle(5);
console.log(circle.describe());
output
// "Area: 78.54, Perimeter: 31.42"

Note Abstract classes cannot be instantiated directly. They can contain both abstract members (no implementation — subclasses must provide them) and concrete members (shared implementation). Use abstract classes when subclasses share behavior; use interfaces when they only share shape.

override Keyword

syntax
class Child extends Parent {
  override method(): Type { ... }
}
example
class Transport {
  getSpeed(): number {
    return 0;
  }

  describe(): string {
    return `Speed: ${this.getSpeed()} km/h`;
  }
}

class Train extends Transport {
  override getSpeed(): number {
    return 300;
  }

  // override getSped(): number { // Error with noImplicitOverride:
  //   return 300;                 // 'getSped' does not exist on base
  // }
}
output
// override keyword verifies the method actually exists on the parent class

Note Enable noImplicitOverride in tsconfig to require the override keyword on all overriding methods. This catches typos (overriding a method that does not exist on the parent) and refactoring bugs (parent method was renamed but child was not updated).

Decorators (TC39 / TS 5.0+)

syntax
@decorator
class Name { ... }

class Name {
  @decorator accessor prop = value;
}
example
// TS 5.0+ standard decorators (TC39 Stage 3)
function sealed(constructor: Function) {
  Object.seal(constructor);
  Object.seal(constructor.prototype);
}

function log(
  target: any,
  context: ClassMethodDecoratorContext
) {
  const methodName = String(context.name);
  return function (this: any, ...args: any[]) {
    console.log(`Calling ${methodName} with`, args);
    return target.call(this, ...args);
  };
}

@sealed
class OrderService {
  @log
  processOrder(orderId: string) {
    console.log(`Processing ${orderId}`);
  }
}
output
// Calling processOrder with ["ord_123"]
// Processing ord_123

Note TS 5.0 introduced TC39 standard decorators — these are different from the legacy experimental decorators. Standard decorators receive a context object instead of the old three-parameter signature. Set "experimentalDecorators": false (or omit it) to use the new standard. Legacy decorators are still available with "experimentalDecorators": true.

Classes with satisfies & implements

syntax
class Name implements Interface { ... }
example
interface Serializable {
  serialize(): string;
  deserialize(data: string): void;
}

interface Cacheable {
  cacheKey: string;
  ttlSeconds: number;
}

class UserSession implements Serializable, Cacheable {
  cacheKey: string;
  ttlSeconds = 3600;

  constructor(public userId: string, public token: string) {
    this.cacheKey = `session:${userId}`;
  }

  serialize(): string {
    return JSON.stringify({ userId: this.userId, token: this.token });
  }

  deserialize(data: string): void {
    const parsed = JSON.parse(data);
    this.userId = parsed.userId;
    this.token = parsed.token;
  }
}
output
// A class can implement multiple interfaces, enforcing all their contracts

Note implements is purely a compile-time check — it does not inherit any code. Each property and method must be explicitly defined in the class. If the class already matches the interface shape, implements is optional but serves as documentation and catches regressions.

Modules & Declaration Files

Declaration Files (.d.ts)

syntax
// filename.d.ts
declare function fn(param: Type): ReturnType;
declare const value: Type;
example
// global.d.ts
declare function trackEvent(name: string, data?: Record<string, unknown>): void;

declare const APP_VERSION: string;

declare interface ImportMeta {
  env: {
    VITE_API_URL: string;
    VITE_DEBUG: string;
  };
}
output
// .d.ts files provide type info without runtime code

Note Declaration files describe the shape of existing JavaScript code. They contain only type information — no executable code. TypeScript automatically picks up .d.ts files in your project. Most npm packages include their own or have a @types/package companion.

declare Keyword

syntax
declare const name: Type;
declare function fn(): Type;
declare class Name { ... }
declare module "name" { ... }
example
// Tell TypeScript about a global that exists at runtime
declare const gtag: (
  command: "config" | "event",
  targetId: string,
  params?: Record<string, unknown>
) => void;

// Tell TypeScript about a global class
declare class Stripe {
  constructor(apiKey: string);
  charges: {
    create(params: { amount: number; currency: string }): Promise<unknown>;
  };
}

gtag("event", "purchase", { value: 99.99 });
output
// declare tells TypeScript 'trust me, this exists at runtime'

Note declare is for things that already exist in the runtime environment but TypeScript cannot see (global scripts, CDN libraries, injected variables). It emits zero JavaScript. Using declare when the value does not actually exist leads to runtime crashes with no compile-time warning.

Ambient Module Declarations

syntax
declare module "module-name" {
  export function fn(): Type;
  export default value;
}
example
// types/untyped-libs.d.ts

// Type an entire untyped npm package
declare module "color-hash" {
  export default class ColorHash {
    constructor(options?: { lightness: number });
    hex(input: string): string;
    rgb(input: string): [number, number, number];
  }
}

// Wildcard: type all .svg imports
declare module "*.svg" {
  const content: string;
  export default content;
}

// Wildcard: type all .css modules
declare module "*.module.css" {
  const classes: Record<string, string>;
  export default classes;
}
output
// Now: import ColorHash from 'color-hash'; // fully typed

Note Ambient module declarations let you type third-party packages that lack types, or describe non-JS imports (CSS, SVG, images). Wildcard modules (*.ext) match any import path ending with that extension. Place these in a .d.ts file included in your tsconfig.

Module Augmentation

syntax
// In a .ts file (not .d.ts)
import "original-module";

declare module "original-module" {
  interface ExistingInterface {
    newProperty: Type;
  }
}
example
// Augmenting Express Request
import "express";

declare module "express" {
  interface Request {
    userId?: string;
    sessionToken?: string;
    permissions?: string[];
  }
}

// Now in your middleware:
import { Request, Response, NextFunction } from "express";

function authMiddleware(req: Request, res: Response, next: NextFunction) {
  req.userId = "usr_123";        // No error — augmented
  req.permissions = ["read"];    // No error — augmented
  next();
}
output
// The augmented properties are available everywhere Request is used

Note Module augmentation extends existing module types without modifying the original package. The file performing augmentation must be a module itself (have at least one import/export). This only works for interfaces and namespaces — you cannot add new top-level exports through augmentation.

Triple-Slash Directives

syntax
/// <reference path="..." />
/// <reference types="..." />
/// <reference lib="..." />
example
/// <reference types="vite/client" />
// Enables Vite-specific types like import.meta.env

/// <reference lib="es2022" />
// Includes ES2022 library types

/// <reference path="./custom-globals.d.ts" />
// Explicitly includes another declaration file
output
// Triple-slash directives are special comments that instruct the compiler

Note Triple-slash directives must appear at the very top of the file (before any code). They are mostly obsolete for modern projects — tsconfig's 'types', 'lib', and 'include' options do the same job. The main surviving use case is /// <reference types="..." /> in .d.ts files and Vite/framework setups.

Namespaces vs Modules

syntax
// Namespace (legacy)
namespace Name { export ... }
// Module (modern)
export ...
import { ... } from "...";
example
// LEGACY: Namespace (avoid in new code)
namespace Validation {
  export interface Rule {
    validate(input: string): boolean;
  }

  export class EmailRule implements Rule {
    validate(input: string): boolean {
      return input.includes("@");
    }
  }
}

// MODERN: Use ES modules instead
// validation.ts
export interface Rule {
  validate(input: string): boolean;
}

export class EmailRule implements Rule {
  validate(input: string): boolean {
    return input.includes("@");
  }
}
output
// ES modules are the standard; namespaces are legacy

Note Namespaces (formerly 'internal modules') are largely obsolete. Modern TypeScript projects should use ES modules (import/export) exclusively. Namespaces still appear in declaration files for global libraries and in legacy codebases. Do not use namespace and module imports in the same file.

Configuration

strict Mode

syntax
// tsconfig.json
{ "compilerOptions": { "strict": true } }
example
// strict: true enables ALL of these at once:
// - strictNullChecks: null/undefined not assignable to other types
// - strictFunctionTypes: contravariant function parameter checking
// - strictBindCallApply: type-check bind, call, apply
// - strictPropertyInitialization: class props must be initialized
// - noImplicitAny: error on implicit any
// - noImplicitThis: error on 'this' with implicit any type
// - useUnknownInCatchVariables: catch variable is unknown, not any
// - alwaysStrict: emit "use strict" in every file
output
// strict: true is the recommended baseline for all new projects

Note Always start new projects with strict: true. You can selectively disable individual checks if needed (e.g., "strictPropertyInitialization": false) while keeping the rest. Enabling strict on a large existing JS-to-TS migration can produce thousands of errors — enable checks incrementally in that case.

target & module

syntax
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext"
  }
}
example
// target: which JS version to emit
// "ES5" — maximum compat (IE11), heavy downleveling
// "ES2020" — good baseline for modern browsers
// "ES2022" — class fields, top-level await, cause in Error
// "ESNext" — latest; may change between TS versions

// module: which module system to use
// "commonjs" — Node.js require/exports
// "es2022" — ES modules with top-level await
// "esnext" — latest ES module features
// "node16" / "nodenext" — Node.js ESM with .mjs/.cjs awareness
// "preserve" — keep import/export as written (TS 5.4+)
output
// target controls syntax downleveling; module controls import/export format

Note For bundled apps (Vite, webpack), set target to your browser baseline and module to ESNext — the bundler handles the rest. For Node.js, use module: "node16" or "nodenext" which correctly resolves .mjs/.cjs and package.json "type" fields. The new "preserve" module option (TS 5.4+) emits imports exactly as written.

paths & baseUrl

syntax
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@alias/*": ["src/folder/*"]
    }
  }
}
example
// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@components/*": ["src/components/*"],
      "@utils/*": ["src/utils/*"],
      "@api/*": ["src/api/*"],
      "@types/*": ["src/types/*"]
    }
  }
}

// In your code:
import { Button } from "@components/Button";
import { formatDate } from "@utils/date";
import type { User } from "@types/user";
output
// Path aliases shorten deep relative imports like ../../../../utils

Note paths only affects TypeScript's type resolution — it does NOT rewrite imports in emitted JS. Your bundler (Vite, webpack) or runtime (ts-node, tsx) needs matching alias config. For Node.js, package.json "imports" with subpath patterns is the modern alternative that works without bundler config.

noEmit & declaration

syntax
{
  "compilerOptions": {
    "noEmit": true,
    "declaration": true
  }
}
example
// noEmit: true — only type-check, don't produce .js files
// Used when a bundler (Vite, esbuild, SWC) handles transpilation
{
  "compilerOptions": {
    "noEmit": true  // tsc is just the type checker
  }
}

// declaration: true — generate .d.ts files alongside .js
// Used for libraries that need to ship type definitions
{
  "compilerOptions": {
    "declaration": true,
    "declarationDir": "./dist/types",
    "emitDeclarationOnly": true  // only .d.ts, no .js
  }
}
output
// noEmit for apps (bundler compiles); declaration for libraries (ship types)

Note Most modern app setups use noEmit: true because tools like Vite, esbuild, and SWC transpile much faster than tsc. Library authors use declaration: true (often with emitDeclarationOnly) to generate .d.ts files while letting another tool handle .js output.

isolatedModules & verbatimModuleSyntax

syntax
{
  "compilerOptions": {
    "isolatedModules": true,
    "verbatimModuleSyntax": true
  }
}
example
// isolatedModules: true
// Ensures each file can be transpiled independently
// Required for: Babel, esbuild, SWC, Vite
// Disallows: const enums across files, namespace merging across files

// verbatimModuleSyntax: true (TS 5.0+, replaces isolatedModules)
// Forces you to use 'import type' for type-only imports

import type { User } from "./models";       // erased at runtime
import { processUser } from "./handlers";   // kept at runtime

// import { User } from "./models";
// Error with verbatimModuleSyntax: 'User' is a type and must use 'import type'
output
// Enforces clean separation of type imports from value imports

Note verbatimModuleSyntax (TS 5.0+) supersedes both isolatedModules and the older importsNotUsedAsValues. It makes the distinction between type and value imports explicit and mandatory. This produces cleaner output and avoids side-effect confusion. Recommended for all new projects.

Project References

syntax
// tsconfig.json
{
  "references": [
    { "path": "./packages/core" },
    { "path": "./packages/api" }
  ]
}
example
// Root tsconfig.json
{
  "files": [],
  "references": [
    { "path": "./packages/shared" },
    { "path": "./packages/server" },
    { "path": "./packages/client" }
  ]
}

// packages/server/tsconfig.json
{
  "compilerOptions": {
    "composite": true,
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "references": [
    { "path": "../shared" }
  ]
}

// Build with: tsc --build
output
// tsc --build only recompiles packages that changed

Note Project references enable incremental builds in monorepos. Each sub-project needs "composite": true. Build with tsc --build (or tsc -b) which understands dependency order. This dramatically speeds up type-checking in large codebases by skipping unchanged packages.

Essential Compiler Options

syntax
// tsconfig.json compilerOptions
example
{
  "compilerOptions": {
    "strict": true,
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "allowImportingTsExtensions": true,
    "noEmit": true,
    "jsx": "react-jsx",
    "lib": ["ES2022", "DOM", "DOM.Iterable"]
  },
  "include": ["src"]
}
output
// A solid starting tsconfig for a modern bundled web app

Note moduleResolution: "bundler" (TS 5.0+) matches how Vite/webpack/esbuild resolve modules — it supports exports maps, .ts extensions in imports, and does not require file extensions. skipLibCheck: true speeds up compilation by not checking node_modules .d.ts files (only your code is checked).

Common Patterns

Type-Safe Event Emitter

syntax
interface Events {
  eventName: (payload: Type) => void;
}
class Emitter<T extends Record<string, (...args: any[]) => void>> { ... }
example
type EventMap = {
  userLogin: (userId: string, timestamp: Date) => void;
  purchase: (orderId: string, amount: number) => void;
  error: (error: Error) => void;
};

class TypedEmitter<T extends Record<string, (...args: any[]) => void>> {
  private handlers = new Map<keyof T, Set<Function>>();

  on<K extends keyof T>(event: K, handler: T[K]): void {
    if (!this.handlers.has(event)) this.handlers.set(event, new Set());
    this.handlers.get(event)!.add(handler);
  }

  emit<K extends keyof T>(event: K, ...args: Parameters<T[K]>): void {
    this.handlers.get(event)?.forEach(fn => fn(...args));
  }
}

const bus = new TypedEmitter<EventMap>();
bus.on("purchase", (orderId, amount) => {
  console.log(`Order ${orderId}: $${amount}`);
});
bus.emit("purchase", "ord_1", 49.99);
output
// Full autocomplete on event names, parameter types, and callback signatures

Note This pattern maps event names to callback signatures. Parameters<T[K]> extracts the argument tuple so emit() is fully typed. This is the foundation used by libraries like mitt, EventEmitter3, and socket.io.

Type-Safe Builder Pattern

syntax
class Builder<T> {
  set<K extends keyof T>(key: K, value: T[K]): this { ... }
  build(): T { ... }
}
example
interface EmailMessage {
  to: string;
  subject: string;
  body: string;
  cc?: string[];
  priority?: "low" | "normal" | "high";
}

class EmailBuilder {
  private message: Partial<EmailMessage> = {};

  to(address: string): this {
    this.message.to = address;
    return this;
  }
  subject(text: string): this {
    this.message.subject = text;
    return this;
  }
  body(html: string): this {
    this.message.body = html;
    return this;
  }
  cc(addresses: string[]): this {
    this.message.cc = addresses;
    return this;
  }
  priority(level: EmailMessage["priority"]): this {
    this.message.priority = level;
    return this;
  }
  build(): EmailMessage {
    if (!this.message.to || !this.message.subject || !this.message.body) {
      throw new Error("to, subject, and body are required");
    }
    return this.message as EmailMessage;
  }
}

const email = new EmailBuilder()
  .to("[email protected]")
  .subject("Deploy complete")
  .body("<p>All checks passed.</p>")
  .priority("high")
  .build();
output
// Fluent API with full type safety and autocomplete at each step

Note Returning 'this' instead of the class name enables proper chaining even in subclasses. For compile-time enforcement of required fields (instead of runtime throws), use a step builder pattern with generics that track which fields have been set.

Exhaustive Switch / If-Else

syntax
function assertNever(value: never): never {
  throw new Error(`Unexpected: ${value}`);
}
example
type PaymentMethod = "card" | "bank" | "crypto" | "paypal";

function processPayment(method: PaymentMethod): string {
  switch (method) {
    case "card":
      return "Processing card payment";
    case "bank":
      return "Processing bank transfer";
    case "crypto":
      return "Processing crypto payment";
    case "paypal":
      return "Processing PayPal";
    default:
      // If a new member is added to PaymentMethod,
      // this line causes a compile error
      const _exhaustive: never = method;
      throw new Error(`Unknown method: ${_exhaustive}`);
  }
}
output
// Adding a fifth payment method causes a compile error at the never assignment

Note The never trick ensures you handle every union member. When a new member is added, TypeScript sees it is not handled and cannot assign it to never. This is critical for state machines, reducers, and any logic that must cover all cases. Some teams extract this into a shared assertNever utility function.

const Assertions

syntax
const value = expression as const;
example
// Without as const: types are widened
const config = { apiUrl: "https://api.example.com", retries: 3 };
// { apiUrl: string; retries: number }

// With as const: literal types + readonly
const frozenConfig = {
  apiUrl: "https://api.example.com",
  retries: 3,
  features: ["search", "export"],
} as const;
// { readonly apiUrl: "https://api.example.com"; readonly retries: 3; readonly features: readonly ["search", "export"] }

// Deriving a union type from const values
const ROLES = ["admin", "editor", "viewer"] as const;
type Role = (typeof ROLES)[number];
// "admin" | "editor" | "viewer"
output
// as const freezes the type to its most specific literal form

Note as const makes all properties readonly and infers the narrowest possible literal types. It applies recursively to nested objects and arrays. This is the idiomatic way to derive string literal unions from runtime arrays, replacing enums in many cases.

Result Type Pattern

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

function parseAge(input: string): Result<number> {
  const num = parseInt(input, 10);
  if (isNaN(num)) return { ok: false, error: "Not a valid number" };
  if (num < 0 || num > 150) return { ok: false, error: "Age out of range" };
  return { ok: true, value: num };
}

const result = parseAge("25");
if (result.ok) {
  console.log(`Age: ${result.value}`); // narrowed to { ok: true; value: number }
} else {
  console.log(`Invalid: ${result.error}`); // narrowed to { ok: false; error: string }
}
output
// Discriminated union forces callers to handle both success and failure

Note The Result pattern replaces throwing exceptions with explicit return types. Callers cannot forget to handle the error case because TypeScript requires narrowing before accessing .value or .error. This is inspired by Rust's Result<T, E> type.

Type-Safe Runtime Validation

syntax
// Using infer with validation libraries
type Inferred = z.infer<typeof schema>;
example
// Zod-style schema → derived type (no duplication)
import { z } from "zod";

const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1).max(100),
  email: z.string().email(),
  age: z.number().int().min(0).max(150).optional(),
  role: z.enum(["admin", "user", "guest"]),
});

// Derive the TypeScript type from the schema
type User = z.infer<typeof UserSchema>;
// { id: string; name: string; email: string; age?: number; role: "admin" | "user" | "guest" }

function createUser(raw: unknown): User {
  return UserSchema.parse(raw); // throws if invalid
}
output
// Single schema defines both runtime validation and compile-time type

Note Libraries like Zod, Valibot, and ArkType let you define a schema once and derive the TypeScript type from it. This eliminates the common problem of types and validation getting out of sync. z.infer<typeof schema> is a powerful pattern that keeps your types as the single source of truth.

Type-Safe Action / Reducer Pattern

syntax
type Action = { type: "name"; payload: Type } | ...;
function reducer(state: State, action: Action): State { ... }
example
interface AppState {
  count: number;
  message: string;
}

type AppAction =
  | { type: "increment"; amount: number }
  | { type: "decrement"; amount: number }
  | { type: "setMessage"; message: string }
  | { type: "reset" };

function reducer(state: AppState, action: AppAction): AppState {
  switch (action.type) {
    case "increment":
      return { ...state, count: state.count + action.amount };
    case "decrement":
      return { ...state, count: state.count - action.amount };
    case "setMessage":
      return { ...state, message: action.message };
    case "reset":
      return { count: 0, message: "" };
  }
}
output
// Each case narrows the action type, giving access to case-specific fields

Note This is the standard pattern for Redux, useReducer, and state machines. The 'type' property serves as the discriminant. TypeScript narrows automatically in each case, so action.amount is only available in increment/decrement cases. Add a default never check for exhaustiveness.

Common Mistakes

Overusing any

syntax
// BAD: any everywhere
function process(data: any): any { ... }
// GOOD: use proper types
function process(data: unknown): Result { ... }
example
// BAD — any silently disables all type safety
function parseConfig(raw: any) {
  return raw.database.host; // No error even if raw is null
}

// GOOD — unknown forces runtime checks
function parseConfig(raw: unknown): { host: string; port: number } {
  if (
    typeof raw === "object" && raw !== null &&
    "database" in raw &&
    typeof (raw as any).database?.host === "string"
  ) {
    const db = (raw as { database: { host: string; port: number } }).database;
    return db;
  }
  throw new Error("Invalid config shape");
}

// BEST — use a validation library
// const config = ConfigSchema.parse(raw);
output
// any: zero safety. unknown: safe. Validated schema: safest.

Note any is infectious — any value that touches any also becomes any, spreading unsafety silently through your codebase. Use eslint rule @typescript-eslint/no-explicit-any to catch it. Legitimate uses: interfacing with very dynamic libraries, or as a temporary measure during migration.

Unnecessary / Incorrect Generics

syntax
// BAD: generic that adds no value
function bad<T extends string>(s: T): T { return s; }
// GOOD: just use the concrete type
function good(s: string): string { return s; }
example
// BAD — T is unused, just makes the signature confusing
function logValue<T>(value: T): void {
  console.log(value);
}
// GOOD — no generic needed
function logValue(value: unknown): void {
  console.log(value);
}

// BAD — generic is always forced to one type anyway
function fetchUser<T extends User>(id: string): Promise<T> { ... }
// GOOD — just return the concrete type
function fetchUser(id: string): Promise<User> { ... }

// GOOD use of generics — T connects input to output
function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
  return items.map(item => item[key]);
}
output
// Use generics when T appears in at least two positions (input→output link)

Note A generic type parameter is useful when it connects two or more positions: input to output, or one parameter to another. If T appears in only one spot, a concrete type (or unknown) is simpler and clearer. Over-genericizing is a common code smell in TypeScript.

Enum Pitfalls

syntax
// Numeric enum accepts any number
enum Status { Active = 0, Inactive = 1 }
const s: Status = 999; // No error!
example
// PITFALL 1: Numeric enums accept any number
enum Priority { Low, Medium, High }
const p: Priority = 42; // Compiles fine! No type error.

// PITFALL 2: const enums break with isolatedModules
const enum Dir { Up, Down } // Error with isolatedModules

// PITFALL 3: Enums are not tree-shakeable
enum Color { Red, Green, Blue }
// Compiles to a runtime object — bundlers cannot remove unused members

// ALTERNATIVE: Use string literal unions
type Priority = "low" | "medium" | "high";
const p: Priority = "low";
// p = "whatever"; // Error — properly type-safe
output
// String literal unions are simpler, safer, and more compatible

Note Numeric enums have a type safety gap — any number is assignable. String enums are safer but add runtime overhead. const enums are erased but break with Babel/esbuild/SWC. For most cases, string literal unions (optionally with an as const object for runtime values) are the pragmatic choice.

Type Assertion vs Type Guard

syntax
// DANGEROUS: assertion (no runtime check)
const user = data as User;
// SAFE: type guard (runtime check)
if (isUser(data)) { /* data is User */ }
example
interface User {
  id: string;
  name: string;
  email: string;
}

// BAD: assertion — no runtime check, crashes if wrong
const user = JSON.parse(rawJson) as User;
console.log(user.email.toUpperCase()); // Runtime crash if email is missing

// GOOD: type guard — verifies at runtime
function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value &&
    "name" in value &&
    "email" in value
  );
}

const data = JSON.parse(rawJson);
if (isUser(data)) {
  console.log(data.email.toUpperCase()); // Safe
} else {
  console.error("Invalid user data");
}
output
// Assertions trust you blindly; guards verify at runtime

Note Type assertions (as Type) are a compile-time lie — they produce zero runtime code. Type guards produce actual runtime checks. Always prefer guards for external data (API responses, user input, file reads). Assertions are acceptable for values you genuinely control (e.g., DOM elements you know exist).

Implicit any

syntax
// BAD: no annotation, no inference → implicit any
function process(data) { ... }
// GOOD: annotate parameters
function process(data: string) { ... }
example
// These all cause implicit any errors with noImplicitAny:

// Function parameters without annotations
function greet(name) { // Error: Parameter 'name' implicitly has 'any' type
  return `Hello, ${name}`;
}

// Destructured parameters
function render({ title, body }) { // Error on both
  return `<h1>${title}</h1><p>${body}</p>`;
}

// Fix: always annotate function parameters
function greet(name: string) {
  return `Hello, ${name}`;
}

function render({ title, body }: { title: string; body: string }) {
  return `<h1>${title}</h1><p>${body}</p>`;
}
output
// noImplicitAny (enabled by strict: true) catches unannotated parameters

Note noImplicitAny is part of strict mode and catches function parameters that would silently become any. This is one of the most important checks — without it, many functions quietly lose all type safety. Callback parameters in typed contexts (like .map(), .filter()) are inferred and do not trigger this error.

Trusting readonly at Runtime

syntax
// readonly is compile-time only
const arr: readonly number[] = [1, 2, 3];
(arr as number[]).push(4); // No runtime protection
example
interface Config {
  readonly apiKey: string;
  readonly maxRetries: number;
}

const config: Config = { apiKey: "secret", maxRetries: 3 };

// TypeScript prevents this:
// config.apiKey = "changed"; // Error

// But at runtime, nothing stops this:
(config as any).apiKey = "changed"; // Works at runtime!
console.log(config.apiKey); // "changed"

// For real immutability, use Object.freeze:
const safeConfig = Object.freeze({ apiKey: "secret", maxRetries: 3 });
// safeConfig.apiKey = "x"; // Runtime TypeError + compile error
output
// readonly is erased at runtime — use Object.freeze for true immutability

Note readonly, Readonly<T>, and ReadonlyArray are all compile-time only. They are stripped during compilation and provide zero runtime enforcement. Code that bypasses the type system (any casts, JavaScript interop) can still mutate. For sensitive data, combine readonly with Object.freeze or structuredClone.

Structural Typing Surprises

syntax
// TypeScript uses structural (shape) typing, not nominal (name) typing
example
interface Cat {
  name: string;
  purr(): void;
}

interface Robot {
  name: string;
  purr(): void;
}

// These are the SAME type to TypeScript!
const robot: Robot = { name: "RoboCat", purr() { console.log("bzzz"); } };
const cat: Cat = robot; // No error — shapes match

// Excess property checking only works on object literals:
const direct: Cat = {
  name: "Whiskers",
  purr() {},
  // batteries: true, // Error: Object literal may only specify known properties
};

// But NOT on variables:
const obj = { name: "X", purr() {}, batteries: true };
const sneaky: Cat = obj; // No error — extra properties allowed from variables
output
// Two types with the same shape are interchangeable, regardless of name

Note Structural typing means TypeScript cares about shape, not name. Excess property checking only catches extra properties on direct object literals — not on variables. This often surprises developers from C#/Java backgrounds. Use branded types if you need nominal distinction.