Table of Contents

Setup & Environment

TypeScript is a statically typed superset of JavaScript that compiles to plain JS. You need Node.js as the runtime; tsc is the TypeScript compiler.

Installation

# Prerequisite: Node.js
brew install node          # macOS
node --version             # verify

# Global TypeScript compiler
npm install -g typescript
tsc --version              # verify: e.g. Version 5.x.x

# Per-project (recommended for reproducible builds)
npm install -D typescript
npx tsc --version

Project Setup

mkdir ts-refresher && cd ts-refresher
npm init -y
npx tsc --init             # creates tsconfig.json with defaults

# Quick runners — no separate compile step needed during development
npm install -D tsx         # fast ESM-aware runner (recommended)
npx tsx hello.ts

# Alternative: ts-node (CommonJS-oriented)
npm install -D ts-node
npx ts-node hello.ts
Development vs Production
Use tsx during development for instant execution with no build step. Use tsc for production builds — it type-checks and emits optimised JavaScript. Never ship TypeScript source directly.

tsconfig.json Key Options

{
  "compilerOptions": {
    "target": "ES2022",          // emitted JS version
    "module": "NodeNext",        // module system (NodeNext, CommonJS, ESNext)
    "moduleResolution": "NodeNext",
    "lib": ["ES2022"],           // type definitions to include
    "strict": true,              // enables all strict checks — always use this
    "outDir": "./dist",          // compiled output directory
    "rootDir": "./src",          // source root
    "declaration": true,         // emit .d.ts files alongside JS
    "declarationMap": true,      // source maps for .d.ts (IDE go-to-definition)
    "sourceMap": true,           // source maps for debugging
    "incremental": true,         // cache for faster rebuilds
    "skipLibCheck": true,        // skip type-checking .d.ts in node_modules
    "esModuleInterop": true,     // sane CommonJS default import behaviour
    "resolveJsonModule": true,   // allow import of .json files
    "noUncheckedIndexedAccess": true  // arr[i] returns T | undefined — highly recommended
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}
# Compile
npx tsc                    # uses tsconfig.json
npx tsc --noEmit           # type-check only, no output (fast CI check)
npx tsc --watch            # incremental watch mode

# Run compiled output
node dist/hello.js

Type Basics

TypeScript adds a static type layer on top of JavaScript. Types are erased at compile time — the emitted JS is identical to what you would have written by hand.

Primitive Types

// The 7 primitives — same as JavaScript's runtime types
let name: string = "Alice";
let age: number = 30;          // covers integers AND floats
let active: boolean = true;
let nothing: null = null;
let missing: undefined = undefined;
let sym: symbol = Symbol("id");
let big: bigint = 9007199254740993n;

// TypeScript INFERS types — explicit annotations are optional when obvious
let city = "NYC";              // inferred: string
let count = 0;                 // inferred: number
let flags = [true, false];     // inferred: boolean[]

// Annotation is valuable when the inferred type is too wide
let status: "active" | "inactive" = "active";  // literal type

any vs unknown vs never

// any — opt out of type checking entirely. Avoid in new code.
let x: any = 42;
x = "hello";           // fine
x.foo.bar.baz;         // no error — type safety gone

// unknown — safe counterpart to any. Must narrow before use.
let y: unknown = fetchData();
y.trim();              // Error: Object is of type 'unknown'
if (typeof y === "string") {
  y.trim();            // OK — narrowed to string
}

// never — represents code that never completes (throws, infinite loop)
// or the empty type (no value can exist)
function fail(msg: string): never {
  throw new Error(msg);  // return type is never
}

// never is the bottom type — it's assignable TO any type,
// but nothing is assignable TO never (except never itself)
function assertUnreachable(x: never): never {
  throw new Error("Unreachable: " + x);
}

// void — used for functions that return nothing meaningful
function log(msg: string): void {
  console.log(msg);
  // implicit return undefined
}
// void vs undefined: void says "I don't care about the return value"
// undefined says "the return value IS undefined"
Avoid any
Using any disables the type checker for that value and can silently propagate — any is contagious. Prefer unknown when the type is genuinely unknown, then narrow it. Enable noImplicitAny (included in strict) to catch accidental any.

Type Widening & Narrowing

// Widening: TypeScript widens literal types in mutable contexts
let greeting = "hello";        // type: string (widened from "hello")
const salutation = "hello";    // type: "hello" (literal — const can't change)

// To prevent widening, use 'as const' or an explicit literal annotation
let direction = "north" as const;    // type: "north"
let mode: "read" | "write" = "read"; // explicit union prevents widening

Arrays & Tuples

Arrays

// Two syntaxes — identical semantics
const nums: number[] = [1, 2, 3];
const strs: Array<string> = ["a", "b", "c"];

// Readonly arrays — prevents mutation
const frozen: readonly number[] = [1, 2, 3];
const frozen2: ReadonlyArray<number> = [1, 2, 3];
frozen.push(4);    // Error: Property 'push' does not exist on type 'readonly number[]'

// Array of objects
interface Point { x: number; y: number; }
const points: Point[] = [{ x: 0, y: 0 }, { x: 1, y: 1 }];

// Mixed-type array (usually a design smell — prefer tuples or unions)
const mixed: (string | number)[] = ["Alice", 30, "Bob", 25];

Tuples

// Tuple: fixed-length array with known types at each index
let pair: [string, number] = ["Alice", 30];
pair[0].toUpperCase();   // OK — string methods available
pair[1].toFixed(2);      // OK — number methods available
pair[2];                 // Error: Tuple type '[string, number]' has no element at index '2'

// Named tuples (TypeScript 4.0+) — improves readability
type UserRecord = [name: string, age: number, active: boolean];
const user: UserRecord = ["Alice", 30, true];

// Optional tuple elements
type WithOptional = [string, number?];
const a: WithOptional = ["hello"];     // OK
const b: WithOptional = ["hello", 42]; // OK

// Rest elements in tuples
type AtLeastTwo = [string, string, ...string[]];
type StringsThenNumber = [...string[], number];

// Readonly tuple
const point: readonly [number, number] = [1, 2];
point[0] = 3;   // Error

// as const — infers the narrowest literal tuple type
const rgb = [255, 128, 0] as const;
// type: readonly [255, 128, 0] — not number[]

// Common use: multiple return values (prefer named objects for > 2 values)
function minMax(arr: number[]): [number, number] {
  return [Math.min(...arr), Math.max(...arr)];
}
const [min, max] = minMax([3, 1, 4, 1, 5]);

Objects & Interfaces

Object Types

// Inline object type annotation
function greet(user: { name: string; age: number }): string {
  return `Hello ${user.name}, you are ${user.age}`;
}

// Optional properties
type Config = {
  host: string;
  port?: number;       // port is string | undefined
  debug?: boolean;
};

// Readonly properties
type ImmutablePoint = {
  readonly x: number;
  readonly y: number;
};
const p: ImmutablePoint = { x: 1, y: 2 };
p.x = 3;  // Error: Cannot assign to 'x' because it is a read-only property

// Index signatures — for dynamic key sets
type StringMap = {
  [key: string]: string;
};
const headers: StringMap = { "Content-Type": "application/json" };

// Index signature with known keys
type FlexibleRecord = {
  id: number;          // known key — must conform to index signature value type
  [key: string]: number | string;  // all other keys
};

Interfaces

// Interface declaration
interface User {
  id: number;
  name: string;
  email?: string;
  readonly createdAt: Date;
}

// Extending interfaces — single and multiple
interface Animal {
  name: string;
}

interface Domestic extends Animal {
  owner: string;
}

interface Pet extends Domestic {
  microchipId?: string;
}

// Extending multiple interfaces
interface FlyingAnimal extends Animal {
  wingspan: number;
}
interface FlyingPet extends Pet, FlyingAnimal {}

// Implementing an interface in a class
interface Serializable {
  serialize(): string;
  deserialize(data: string): void;
}

class JsonDocument implements Serializable {
  private data: Record<string, unknown> = {};

  serialize(): string {
    return JSON.stringify(this.data);
  }
  deserialize(raw: string): void {
    this.data = JSON.parse(raw);
  }
}

// Declaration merging — interfaces with the same name are merged
// This is how @types packages augment built-in types
interface Window {
  myCustomProp: string;
}
// Now window.myCustomProp is typed

Interface vs Type Alias

Featureinterfacetype alias
Object shapesYesYes
Primitives / unions / tuplesNoYes
Declaration mergingYesNo
extends keywordYesVia & intersection
implements in classYesYes (if object shape)
Recursive typesYesYes
Error messagesShows interface nameShows expanded shape
When to use which
Use interface for public API shapes and class contracts — declaration merging lets consumers augment them. Use type alias for unions, intersections, mapped types, and any non-object type. When in doubt, interface first; switch to type when you need features only type supports.

Type Aliases & Unions

Unions and Intersections

// Union type — value can be ONE of the listed types
type StringOrNumber = string | number;
type ID = string | number;

function formatId(id: ID): string {
  return typeof id === "string" ? id : id.toString();
}

// Intersection type — value must satisfy ALL types simultaneously
type Timestamped = { createdAt: Date; updatedAt: Date };
type Named = { name: string };
type NamedTimestamped = Named & Timestamped;

// Intersecting interfaces — useful for composing mixins
interface Loggable { log(): void; }
interface Serializable { serialize(): string; }
type LoggableSerializable = Loggable & Serializable;

Discriminated Unions (Tagged Unions)

// A discriminant is a shared literal property that narrows the type
type Shape =
  | { kind: "circle";    radius: number }
  | { kind: "rectangle"; width: number; height: number }
  | { kind: "triangle";  base: number;  height: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return (shape.base * shape.height) / 2;
    // TypeScript enforces exhaustiveness — adding a new variant
    // without handling it here causes a compile error (see Section 14)
  }
}

// Result type — common pattern for error handling without exceptions
type Result<T, E = Error> =
  | { ok: true;  value: T }
  | { ok: false; error: E };

function parseJson(raw: string): Result<unknown> {
  try {
    return { ok: true, value: JSON.parse(raw) };
  } catch (err) {
    return { ok: false, error: err as Error };
  }
}

const result = parseJson('{"x":1}');
if (result.ok) {
  console.log(result.value);  // TypeScript knows value exists
} else {
  console.error(result.error.message);  // TypeScript knows error exists
}

Type Narrowing

// typeof narrowing
function processInput(input: string | number): string {
  if (typeof input === "string") {
    return input.toUpperCase();  // input: string here
  }
  return input.toFixed(2);       // input: number here
}

// instanceof narrowing
function handleError(err: unknown): string {
  if (err instanceof Error) {
    return err.message;          // err: Error here
  }
  return String(err);
}

// 'in' operator narrowing
type Dog = { bark(): void };
type Cat = { meow(): void };
function makeSound(animal: Dog | Cat) {
  if ("bark" in animal) {
    animal.bark();   // animal: Dog
  } else {
    animal.meow();   // animal: Cat
  }
}

// User-defined type guard — function that returns 'x is T'
function isString(x: unknown): x is string {
  return typeof x === "string";
}

function isNonNull<T>(x: T | null | undefined): x is T {
  return x !== null && x !== undefined;
}

// Assertion function — throws if condition fails, narrows after
function assert(condition: unknown, msg: string): asserts condition {
  if (!condition) throw new Error(msg);
}

function processUser(user: User | null) {
  assert(user !== null, "user must not be null");
  // user is narrowed to User from here on
  console.log(user.name);
}

Functions

Function Type Annotations

// Named function
function add(a: number, b: number): number {
  return a + b;
}

// Arrow function
const multiply = (a: number, b: number): number => a * b;

// Optional and default parameters
function greet(name: string, greeting: string = "Hello", title?: string): string {
  const t = title ? `${title} ` : "";
  return `${greeting}, ${t}${name}!`;
}

greet("Alice");                    // "Hello, Alice!"
greet("Alice", "Hi", "Dr.");       // "Hi, Dr. Alice!"

// Rest parameters
function sum(...nums: number[]): number {
  return nums.reduce((a, b) => a + b, 0);
}

// Void vs undefined return
function sideEffect(): void { console.log("done"); }   // callers ignore return
function explicit(): undefined { return undefined; }    // callers expect undefined

// Callback types
type Predicate<T> = (item: T, index: number) => boolean;
function filter<T>(arr: T[], pred: Predicate<T>): T[] {
  return arr.filter(pred);
}

// 'this' parameter (fake parameter to type the this context)
interface Button {
  label: string;
  onClick(this: Button): void;
}
const btn: Button = {
  label: "Submit",
  onClick() {
    console.log(this.label);  // this is Button, not any
  }
};

Function Overloads

// Overload signatures (no body) followed by implementation signature
function parse(input: string): number;
function parse(input: number): string;
function parse(input: string | number): string | number {
  if (typeof input === "string") return Number(input);
  return String(input);
}

// parse("42")  → number
// parse(42)    → string
// parse(true)  → Error: No overload matches this call

// Overloads with objects — more expressive than union parameters
interface FetchOptions {
  method?: "GET" | "POST";
  body?: string;
}
function fetch(url: string): Promise<Response>;
function fetch(url: string, options: FetchOptions): Promise<Response>;
function fetch(url: string, options?: FetchOptions): Promise<Response> {
  // implementation
  return globalThis.fetch(url, options);
}

Generics

Generics let you write reusable, type-safe code that works over multiple types without sacrificing the type information.

Generic Functions

// Type parameter T captures the input type and threads it through
function identity<T>(value: T): T {
  return value;
}
identity<string>("hello");   // explicit
identity(42);                 // inferred: T = number

// Multiple type parameters
function zip<A, B>(a: A[], b: B[]): [A, B][] {
  return a.map((item, i) => [item, b[i]]);
}
zip([1, 2, 3], ["a", "b", "c"]);  // [number, string][]

// Generic with constraints — T must have a 'length' property
function longest<T extends { length: number }>(a: T, b: T): T {
  return a.length >= b.length ? a : b;
}
longest("hello", "hi");       // works — strings have length
longest([1, 2, 3], [1]);      // works — arrays have length
longest(1, 2);                // Error: number has no 'length'

// Returning a property of T by key
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}
const user = { name: "Alice", age: 30 };
getProperty(user, "name");   // string
getProperty(user, "age");    // number
getProperty(user, "email");  // Error: Argument of type '"email"' is not assignable

Generic Interfaces and Classes

// Generic interface
interface Repository<T> {
  findById(id: number): Promise<T | null>;
  findAll(): Promise<T[]>;
  save(entity: T): Promise<T>;
  delete(id: number): Promise<void>;
}

// Generic class
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; }
  isEmpty(): boolean { return this.items.length === 0; }
}

const numStack = new Stack<number>();
numStack.push(1);
numStack.push(2);
numStack.pop();     // number | undefined

// Default type parameter (TypeScript 2.3+)
interface ApiResponse<T = unknown> {
  data: T;
  status: number;
  message: string;
}
// ApiResponse       — data is unknown
// ApiResponse<User> — data is User

// Infer keyword — extract type from another type
type UnpackPromise<T> = T extends Promise<infer U> ? U : T;

type A = UnpackPromise<Promise<string>>;  // string
type B = UnpackPromise<number>;            // number (not a promise, returns T)

type UnpackArray<T> = T extends (infer U)[] ? U : never;
type C = UnpackArray<string[]>;            // string
type D = UnpackArray<[string, number]>;    // string | number

Utility Types

TypeScript ships a library of generic types that transform other types. These are invaluable for avoiding repetition.

interface User {
  id: number;
  name: string;
  email: string;
  role: "admin" | "user";
  createdAt: Date;
}

// Partial<T> — makes all properties optional (useful for update payloads)
type UserUpdate = Partial<User>;
// { id?: number; name?: string; email?: string; ... }

// Required<T> — makes all properties required (opposite of Partial)
type StrictConfig = Required<Config>;

// Readonly<T> — makes all properties readonly
type FrozenUser = Readonly<User>;

// Record<K, V> — object type with keys K and values V
type RoleMap = Record<"admin" | "user" | "guest", string[]>;
const permissions: RoleMap = {
  admin: ["read", "write", "delete"],
  user:  ["read", "write"],
  guest: ["read"],
};

// Pick<T, K> — select a subset of properties
type UserSummary = Pick<User, "id" | "name">;
// { id: number; name: string }

// Omit<T, K> — exclude a subset of properties
type PublicUser = Omit<User, "email" | "createdAt">;
// { id: number; name: string; role: "admin" | "user" }

// Exclude<T, U> — remove from union T the members assignable to U
type NonAdmin = Exclude<"admin" | "user" | "guest", "admin">;
// "user" | "guest"

// Extract<T, U> — keep only members of T assignable to U
type Admins = Extract<"admin" | "user" | "guest", "admin" | "superadmin">;
// "admin"

// NonNullable<T> — remove null and undefined from T
type DefinitelyString = NonNullable<string | null | undefined>;
// string

// ReturnType<T> — extract the return type of a function type
function createUser(name: string): User { /* ... */ return {} as User; }
type Created = ReturnType<typeof createUser>;  // User

// Parameters<T> — extract parameter types as a tuple
type CreateParams = Parameters<typeof createUser>;  // [name: string]

// Awaited<T> — unwrap a Promise (handles nested promises too)
type Resolved = Awaited<Promise<Promise<string>>>;  // string

// InstanceType<T> — get instance type of a constructor
class DatabaseConnection { query(sql: string): unknown[] { return []; } }
type DBInstance = InstanceType<typeof DatabaseConnection>;  // DatabaseConnection
Utility types cheat sheet
UtilityWhat it doesExample
Partial<T>All props optionalUpdate DTOs
Required<T>All props requiredValidated forms
Readonly<T>All props readonlyImmutable config
Record<K,V>Dict with typed keysLookup tables
Pick<T,K>Subset of propsView models
Omit<T,K>Remove propsPublic interfaces
Exclude<T,U>Remove union membersFiltering types
Extract<T,U>Keep union membersFiltering types
NonNullable<T>Remove null/undefinedAfter null checks
ReturnType<F>Function return typeInfer from impl
Parameters<F>Function param tupleWrapping functions
Awaited<T>Unwrap PromiseAsync return types
InstanceType<C>Class instance typeFactory patterns

Advanced Types

Conditional Types

// T extends U ? X : Y — evaluated at type level like a ternary
type IsString<T> = T extends string ? true : false;

type A = IsString<string>;   // true
type B = IsString<number>;   // false

// Distributive conditional types — applied to each member of a union
type ToArray<T> = T extends unknown ? T[] : never;
type C = ToArray<string | number>;  // string[] | number[]

// Non-distributive: wrap in a tuple to prevent distribution
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;
type D = ToArrayNonDist<string | number>;  // (string | number)[]

// Conditional types with infer
type ReturnTypeOf<F> = F extends (...args: unknown[]) => infer R ? R : never;
type PromiseValue<T> = T extends Promise<infer V> ? V : T;
type FirstArg<F> = F extends (first: infer A, ...rest: unknown[]) => unknown ? A : never;

Mapped Types

// Iterate over keys to transform a type
type Nullable<T> = { [K in keyof T]: T[K] | null };
type Optional<T> = { [K in keyof T]?: T[K] };
type Mutable<T> = { -readonly [K in keyof T]: T[K] };   // remove readonly
type Complete<T> = { [K in keyof T]-?: T[K] };            // remove optionality

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

interface Point { x: number; y: number; }
type PointGetters = Getters<Point>;
// { getX: () => number; getY: () => number; }

// Filtering properties by type using conditional remapping
type PickByType<T, ValueType> = {
  [K in keyof T as T[K] extends ValueType ? K : never]: T[K];
};

interface Mixed { name: string; age: number; active: boolean; score: number; }
type StringProps = PickByType<Mixed, string>;   // { name: string }
type NumberProps = PickByType<Mixed, number>;   // { age: number; score: number }

Template Literal Types

// Build string literal types programmatically
type EventName = "click" | "focus" | "blur";
type HandlerName = `on${Capitalize<EventName>}`;
// "onClick" | "onFocus" | "onBlur"

// Typing CSS property names
type CSSUnit = "px" | "em" | "rem" | "%";
type CSSValue = `${number}${CSSUnit}`;
// const size: CSSValue = "16px";  — this works
// const size: CSSValue = "16vh";  — Error

// Route parameter extraction
type RouteParams<Route extends string> =
  Route extends `${string}:${infer Param}/${infer Rest}`
    ? Param | RouteParams<`/${Rest}`>
    : Route extends `${string}:${infer Param}`
      ? Param
      : never;

type Params = RouteParams<"/users/:userId/posts/:postId">;
// "userId" | "postId"

// keyof and typeof
const config = { host: "localhost", port: 5432, db: "mydb" };
type ConfigKey = keyof typeof config;   // "host" | "port" | "db"
type ConfigValue = (typeof config)[ConfigKey];  // string | number

// Indexed access types
interface Article { title: string; author: { name: string; email: string }; }
type AuthorName = Article["author"]["name"];  // string
type ArticleField = Article[keyof Article];   // string | { name: string; email: string }

Enums & Const

Numeric and String Enums

// Numeric enum — auto-increments from 0 (or specified value)
enum Direction {
  Up,       // 0
  Down,     // 1
  Left,     // 2
  Right,    // 3
}
enum HttpStatus {
  OK = 200,
  Created = 201,
  BadRequest = 400,
  NotFound = 404,
}

// String enum — explicit values required, no reverse mapping
enum LogLevel {
  Debug = "DEBUG",
  Info  = "INFO",
  Warn  = "WARN",
  Error = "ERROR",
}

function log(level: LogLevel, msg: string): void {
  console.log(`[${level}] ${msg}`);
}
log(LogLevel.Info, "Server started");
// log("INFO", "bad") — Error: Argument of type '"INFO"' is not assignable

// Const enum — inlined at compile time, no runtime object emitted
const enum Color {
  Red   = "red",
  Green = "green",
  Blue  = "blue",
}
const c = Color.Red;  // compiled to: const c = "red";

Enum Pitfalls and Alternatives

// Pitfall 1: Numeric enums accept any number
enum Status { Active = 1, Inactive = 2 }
const s: Status = 999;  // No error! TypeScript allows this for numeric enums.

// Pitfall 2: Numeric enum reverse mapping leaks runtime values
// Object.values(Direction) === [0, 1, 2, 3, "Up", "Down", "Left", "Right"]

// Pitfall 3: const enums break with isolatedModules (Babel, Vite, esbuild)
// because they require type information at transpile time.

// Alternative: 'as const' object — no runtime overhead, full type safety
const DIRECTION = {
  Up:    "UP",
  Down:  "DOWN",
  Left:  "LEFT",
  Right: "RIGHT",
} as const;

type Direction = (typeof DIRECTION)[keyof typeof DIRECTION];
// "UP" | "DOWN" | "LEFT" | "RIGHT"

function move(dir: Direction): void {
  console.log(`Moving ${dir}`);
}
move(DIRECTION.Up);   // OK
move("UP");           // OK — literal assignable to union
move("DIAGONAL");     // Error

// Alternative: plain union type (simplest, when you only need the type)
type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR";
Prefer union types or as const over enums
Union string types and as const objects have zero runtime cost, work cleanly with all bundlers, and avoid numeric enum's reverse-mapping surprise. Reserve enums for cases where you specifically need an enum object at runtime (e.g., for iteration over values).

Classes

Access Modifiers

class BankAccount {
  // Parameter property shorthand — declare and initialise in constructor
  constructor(
    public readonly id: string,       // accessible everywhere, immutable
    private balance: number,          // accessible only within BankAccount
    protected owner: string,          // accessible in BankAccount + subclasses
  ) {}

  deposit(amount: number): void {
    if (amount <= 0) throw new Error("Amount must be positive");
    this.balance += amount;
  }

  withdraw(amount: number): void {
    if (amount > this.balance) throw new Error("Insufficient funds");
    this.balance -= amount;
  }

  getBalance(): number {
    return this.balance;             // controlled read access
  }
}

// TypeScript private vs JavaScript #private
class Secure {
  private tsPrivate = 1;    // TypeScript-only: erased at runtime, visible in JS
  #jsPrivate = 2;           // Hard private: enforced by JavaScript engine

  getTs() { return this.tsPrivate; }
  getJs() { return this.#jsPrivate; }
}
const s = new Secure();
(s as any).tsPrivate;  // "works" at runtime — TS only enforces at type level
// s.#jsPrivate;        // Syntax error — truly private

Abstract Classes

// Abstract classes define a contract but cannot be instantiated directly
abstract class Animal {
  constructor(public readonly name: string) {}

  // Abstract method — subclass MUST implement
  abstract makeSound(): string;

  // Concrete method — inherited as-is
  describe(): string {
    return `${this.name} says: ${this.makeSound()}`;
  }
}

class Dog extends Animal {
  makeSound(): string { return "Woof!"; }
}

class Cat extends Animal {
  makeSound(): string { return "Meow!"; }
}

// new Animal("x")  — Error: Cannot create an instance of an abstract class
const dog = new Dog("Rex");
dog.describe();  // "Rex says: Woof!"

Static Members and Decorators

// Static members belong to the class itself, not instances
class Counter {
  private static count = 0;
  readonly id: number;

  constructor() {
    Counter.count++;
    this.id = Counter.count;
  }

  static getCount(): number { return Counter.count; }
  static reset(): void { Counter.count = 0; }
}

const a = new Counter();  // id: 1
const b = new Counter();  // id: 2
Counter.getCount();        // 2

// Singleton pattern using static
class Config {
  private static instance: Config | null = null;
  private data: Record<string, string> = {};

  private constructor() {}   // private constructor prevents 'new Config()'

  static getInstance(): Config {
    if (!Config.instance) {
      Config.instance = new Config();
    }
    return Config.instance;
  }

  get(key: string): string | undefined { return this.data[key]; }
  set(key: string, value: string): void { this.data[key] = value; }
}

// Decorators (Stage 3 proposal, TypeScript 5.0+)
// Enable with "experimentalDecorators": true in tsconfig
function log(target: unknown, key: string, descriptor: PropertyDescriptor) {
  const original = descriptor.value;
  descriptor.value = function (...args: unknown[]) {
    console.log(`Calling ${key} with`, args);
    return original.apply(this, args);
  };
  return descriptor;
}

class Service {
  // @log   // would log every call to processRequest
  processRequest(req: string): string { return `processed: ${req}`; }
}

Modules & Namespaces

ES Modules

// math.ts — named exports
export function add(a: number, b: number): number { return a + b; }
export function subtract(a: number, b: number): number { return a - b; }
export const PI = 3.14159;

// types.ts — exporting types
export interface User { id: number; name: string; }
export type UserId = number;

// main.ts — importing
import { add, PI } from "./math.js";        // note: .js extension in ESM!
import { add as plus } from "./math.js";    // rename
import * as math from "./math.js";          // namespace import
import type { User } from "./types.js";     // type-only import (erased at runtime)

// Default export (use sparingly — prefer named exports for tree-shaking)
// logger.ts
export default class Logger {
  log(msg: string): void { console.log(msg); }
}
// main.ts
import Logger from "./logger.js";           // any name works for default import

// Re-exporting
export { add, subtract } from "./math.js";
export type { User } from "./types.js";
export * from "./math.js";                  // re-export all

Type-Only Imports

// import type — guarantees no runtime code is emitted
// Use when you only need the type, not the value
import type { User, UserId } from "./types.js";

// Mixing in a single import (TypeScript 4.5+)
import { createUser, type User as UserType } from "./user.js";

// Why it matters:
// - Prevents circular dependency issues where a module is only needed for types
// - Works correctly with isolatedModules (Babel, esbuild, Vite transpilers)
// - Makes intent clear: this import has zero runtime cost

Declaration Files (.d.ts)

// A .d.ts file describes the shape of a JavaScript module
// without containing any implementation

// math.d.ts
declare function add(a: number, b: number): number;
declare const PI: number;
export { add, PI };

// Ambient declarations — describe globals or modules without imports
// globals.d.ts
declare const __VERSION__: string;         // injected by build tool
declare function gtag(...args: unknown[]): void;  // Google Analytics global

// Ambient module declaration — type an entire third-party module
declare module "some-untyped-lib" {
  export function doThing(input: string): boolean;
  export interface ThingOptions { timeout?: number; }
}

// Module augmentation — add properties to existing module types
// This is how you extend Express's Request type:
import "express";
declare module "express" {
  interface Request {
    user?: { id: number; name: string };
  }
}

Working with Libraries

DefinitelyTyped (@types/*)

# Most popular JS libraries ship types in @types/* packages
npm install -D @types/node      # Node.js built-ins (fs, path, http, etc.)
npm install -D @types/express   # Express types
npm install -D @types/lodash    # Lodash types

# Libraries that bundle their own types (no @types needed)
# axios, zod, prisma, date-fns, react (from v18+), etc.

# Check if types are bundled:
# Look for "types" or "typings" field in the library's package.json

# Verify installed types
cat node_modules/@types/node/package.json | grep '"version"'
// Using @types/node
import * as fs from "fs";
import * as path from "path";
import { promisify } from "util";

const readFile = promisify(fs.readFile);

async function readConfig(filePath: string): Promise<Record<string, unknown>> {
  const resolved = path.resolve(filePath);
  const raw = await readFile(resolved, "utf-8");
  return JSON.parse(raw);
}

Dealing with Untyped Libraries

// Option 1: Quick and dirty — cast to any (avoid in production code)
import legacyLib from "legacy-lib";
(legacyLib as any).doThing("input");

// Option 2: Minimal declaration in your own .d.ts file
// src/types/legacy-lib.d.ts
declare module "legacy-lib" {
  interface LegacyOptions { timeout?: number; }
  function doThing(input: string, options?: LegacyOptions): boolean;
  export = doThing;  // CommonJS default export pattern
}

// Option 3: Type assertion with a guard function
function asRecord(x: unknown): Record<string, unknown> {
  if (typeof x !== "object" || x === null) {
    throw new TypeError("Expected an object");
  }
  return x as Record<string, unknown>;
}

// Option 4: Wrap the library — contain the 'any' at a boundary
class LegacyAdapter {
  private lib: unknown;

  constructor() {
    // eslint-disable-next-line @typescript-eslint/no-var-requires
    this.lib = require("legacy-lib");
  }

  doThing(input: string): boolean {
    // the 'any' is contained here, callers get a typed interface
    return (this.lib as { doThing: (s: string) => boolean }).doThing(input);
  }
}

Strict Mode & Configuration

The strict Flag

Enabling "strict": true in tsconfig activates a suite of sub-flags. Each catches a different class of bug:

Sub-flagWhat it catchesImpact
strictNullChecksForgetting to handle null/undefinedHigh
noImplicitAnyParameters/variables with inferred anyHigh
strictFunctionTypesUnsafe callback parameter typesMedium
strictBindCallApplyWrong types passed to bind/call/applyMedium
strictPropertyInitializationClass props not initialised in constructorMedium
noImplicitThisthis with implicit any typeMedium
useUnknownInCatchVariablescatch clause variable typed unknown, not anyLow
alwaysStrictEmits "use strict" in every fileLow
// Without strictNullChecks — this compiles fine but crashes at runtime:
// function greet(name: string) { return name.toUpperCase(); }
// greet(null);  // TypeError at runtime

// With strictNullChecks — caught at compile time:
function greet(name: string | null): string {
  if (name === null) return "Hello, stranger!";
  return name.toUpperCase();  // name is narrowed to string here
}

// Without strictFunctionTypes — unsound but allowed:
// type Handler = (event: MouseEvent) => void;
// const fn: Handler = (e: Event) => {};  // Event is wider — unsafe

// With strictFunctionTypes — correctly rejected:
// Function parameters are checked contravariantly

Additional Recommended Checks

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,   // arr[i] returns T | undefined
    "noImplicitReturns": true,           // all code paths must return a value
    "noFallthroughCasesInSwitch": true,  // prevents accidental fallthrough
    "exactOptionalPropertyTypes": true,  // { x?: string } forbids assigning undefined explicitly
    "noPropertyAccessFromIndexSignature": true  // forces bracket notation for dynamic keys
  }
}

Path Mapping and Project References

// tsconfig.json — path aliases (must also configure your bundler)
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*":        ["./src/*"],
      "@lib/*":     ["./src/lib/*"],
      "@types/*":   ["./src/types/*"]
    }
  }
}
// Now you can write:
import { UserService } from "@/services/user";
import type { User } from "@types/user";
// Instead of: import { UserService } from "../../services/user"
// Project references — for monorepos: separate tsconfig per package
// packages/core/tsconfig.json
{
  "compilerOptions": { "composite": true, "outDir": "./dist" }
}

// packages/api/tsconfig.json
{
  "references": [{ "path": "../core" }],
  "compilerOptions": { "composite": true }
}

// Build: tsc --build packages/api  (builds core first, then api)
// Incremental: tsc --build --watch

Common Patterns

Builder Pattern with Types

// Fluent builder that enforces required fields at compile time
interface QueryConfig {
  table: string;
  conditions: string[];
  limit?: number;
  orderBy?: string;
}

class QueryBuilder {
  private config: Partial<QueryConfig> = { conditions: [] };

  from(table: string): this {
    this.config.table = table;
    return this;
  }

  where(condition: string): this {
    this.config.conditions!.push(condition);
    return this;
  }

  orderBy(column: string): this {
    this.config.orderBy = column;
    return this;
  }

  limit(n: number): this {
    this.config.limit = n;
    return this;
  }

  build(): QueryConfig {
    if (!this.config.table) throw new Error("table is required");
    return this.config as QueryConfig;
  }
}

const query = new QueryBuilder()
  .from("users")
  .where("active = true")
  .orderBy("name")
  .limit(10)
  .build();

Branded Types (Nominal Typing)

// TypeScript is structurally typed — two types with the same shape are identical.
// Branded types add a phantom type tag to distinguish them.

declare const __brand: unique symbol;
type Brand<T, B> = T & { readonly [__brand]: B };

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

// Constructor functions validate and return the branded type
function makeUserId(id: number): UserId {
  if (id <= 0) throw new Error("UserId must be positive");
  return id as UserId;
}

function makeEmail(email: string): Email {
  if (!email.includes("@")) throw new Error("Invalid email");
  return email.toLowerCase() as Email;
}

function getUserById(id: UserId): Promise<User> {
  // ...
  return Promise.resolve({} as User);
}

const userId  = makeUserId(42);
const orderId = 42 as OrderId;

getUserById(userId);   // OK
getUserById(42);       // Error: number is not assignable to UserId
getUserById(orderId);  // Error: OrderId is not assignable to UserId

Exhaustive Switch with never

// The exhaustive check pattern: if TypeScript reaches the default case,
// the value's type should be never (all cases handled)

type Shape =
  | { kind: "circle";    radius: number }
  | { kind: "square";    side: number }
  | { kind: "triangle";  base: number; height: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.side ** 2;
    case "triangle":
      return (shape.base * shape.height) / 2;
    default: {
      // If you add a new Shape variant without handling it above,
      // TypeScript will error here because shape won't be never.
      const _exhaustive: never = shape;
      throw new Error(`Unhandled shape: ${JSON.stringify(_exhaustive)}`);
    }
  }
}

// Alternative: a utility function
function assertNever(x: never, message = "Unhandled case"): never {
  throw new Error(`${message}: ${JSON.stringify(x)}`);
}

function describeShape(shape: Shape): string {
  switch (shape.kind) {
    case "circle":   return `Circle r=${shape.radius}`;
    case "square":   return `Square s=${shape.side}`;
    case "triangle": return `Triangle b=${shape.base} h=${shape.height}`;
    default:         return assertNever(shape);
  }
}

Type-Safe Event Emitter

// Map event names to their payload types
type EventMap = {
  "user:created": { id: number; name: string };
  "user:deleted": { id: number };
  "order:placed": { orderId: string; total: number };
};

type EventHandler<T> = (payload: T) => void;

class TypedEventEmitter<Events extends Record<string, unknown>> {
  private listeners = new Map<keyof Events, Set<EventHandler<unknown>>>();

  on<K extends keyof Events>(event: K, handler: EventHandler<Events[K]>): this {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event)!.add(handler as EventHandler<unknown>);
    return this;
  }

  off<K extends keyof Events>(event: K, handler: EventHandler<Events[K]>): this {
    this.listeners.get(event)?.delete(handler as EventHandler<unknown>);
    return this;
  }

  emit<K extends keyof Events>(event: K, payload: Events[K]): void {
    this.listeners.get(event)?.forEach(handler => handler(payload));
  }
}

const emitter = new TypedEventEmitter<EventMap>();

emitter.on("user:created", (payload) => {
  console.log(payload.name);  // TypeScript knows payload is { id, name }
});

emitter.emit("user:created", { id: 1, name: "Alice" });   // OK
emitter.emit("user:created", { id: 1 });                   // Error: missing 'name'
emitter.emit("unknown:event", {});                          // Error: not in EventMap

Runtime Validation with Zod

npm install zod
import { z } from "zod";

// Define schema — Zod infers the TypeScript type
const UserSchema = z.object({
  id:    z.number().positive(),
  name:  z.string().min(1).max(100),
  email: z.string().email(),
  role:  z.enum(["admin", "user", "guest"]),
  createdAt: z.coerce.date(),         // coerces string/number to Date
});

// Infer the TypeScript type from the schema
type User = z.infer<typeof UserSchema>;

// Parse (throws on invalid input)
function parseUser(raw: unknown): User {
  return UserSchema.parse(raw);   // throws ZodError if invalid
}

// Safe parse (returns { success, data } or { success: false, error })
function safeParseUser(raw: unknown): { user: User } | { error: string } {
  const result = UserSchema.safeParse(raw);
  if (!result.success) {
    return { error: result.error.issues.map(i => i.message).join(", ") };
  }
  return { user: result.data };
}

// Schema composition
const CreateUserSchema = UserSchema.omit({ id: true, createdAt: true });
const UpdateUserSchema = UserSchema.partial().required({ id: true });

type CreateUserInput = z.infer<typeof CreateUserSchema>;
type UpdateUserInput = z.infer<typeof UpdateUserSchema>;

// Environment variable validation at startup
const EnvSchema = z.object({
  DATABASE_URL:  z.string().url(),
  PORT:          z.coerce.number().default(3000),
  NODE_ENV:      z.enum(["development", "test", "production"]).default("development"),
  JWT_SECRET:    z.string().min(32),
});

const env = EnvSchema.parse(process.env);
// If any variable is missing or wrong type, process exits with a clear error

Result / Either Pattern

// Explicit error handling without exceptions
// — errors appear in the type signature, callers can't ignore them

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

function ok<T>(value: T): Ok<T>   { return { ok: true,  value }; }
function err<E>(error: E): Err<E> { return { ok: false, error }; }

// Usage
interface ParseError { message: string; line: number; }

function parseLine(raw: string): Result<number[], ParseError> {
  const nums = raw.split(",").map(Number);
  if (nums.some(isNaN)) {
    return err({ message: "Invalid number in line", line: 0 });
  }
  return ok(nums);
}

const result = parseLine("1,2,3");
if (result.ok) {
  console.log(result.value.reduce((a, b) => a + b, 0));  // sum
} else {
  console.error(result.error.message);
}

// Chaining — map over Ok, pass through Err
function mapResult<T, U, E>(
  result: Result<T, E>,
  fn: (value: T) => U,
): Result<U, E> {
  return result.ok ? ok(fn(result.value)) : result;
}

const doubled = mapResult(parseLine("1,2,3"), nums => nums.map(n => n * 2));

Generic Repository Pattern

// A reusable, type-safe data access layer

interface Entity { id: number; }

interface Repository<T extends Entity> {
  findById(id: number): Promise<T | null>;
  findAll(filter?: Partial<T>): Promise<T[]>;
  save(entity: Omit<T, "id">): Promise<T>;
  update(id: number, patch: Partial<Omit<T, "id">>): Promise<T | null>;
  delete(id: number): Promise<boolean>;
}

// In-memory implementation (useful for testing)
class InMemoryRepository<T extends Entity> implements Repository<T> {
  private store = new Map<number, T>();
  private nextId = 1;

  async findById(id: number): Promise<T | null> {
    return this.store.get(id) ?? null;
  }

  async findAll(filter?: Partial<T>): Promise<T[]> {
    const all = Array.from(this.store.values());
    if (!filter) return all;
    return all.filter(entity =>
      Object.entries(filter).every(([k, v]) => entity[k as keyof T] === v)
    );
  }

  async save(data: Omit<T, "id">): Promise<T> {
    const entity = { ...data, id: this.nextId++ } as T;
    this.store.set(entity.id, entity);
    return entity;
  }

  async update(id: number, patch: Partial<Omit<T, "id">>): Promise<T | null> {
    const existing = this.store.get(id);
    if (!existing) return null;
    const updated = { ...existing, ...patch };
    this.store.set(id, updated);
    return updated;
  }

  async delete(id: number): Promise<boolean> {
    return this.store.delete(id);
  }
}

interface UserEntity extends Entity {
  name: string;
  email: string;
}

const userRepo = new InMemoryRepository<UserEntity>();
const alice = await userRepo.save({ name: "Alice", email: "[email protected]" });
// alice.id is number, alice.name is string — fully typed