← All Refreshers
TypeScript Refresher
Type system, generics, utility types, and patterns for type-safe JavaScript
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
| Feature | interface | type alias |
|---|---|---|
| Object shapes | Yes | Yes |
| Primitives / unions / tuples | No | Yes |
| Declaration merging | Yes | No |
| extends keyword | Yes | Via & intersection |
| implements in class | Yes | Yes (if object shape) |
| Recursive types | Yes | Yes |
| Error messages | Shows interface name | Shows 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
| Utility | What it does | Example |
|---|---|---|
Partial<T> | All props optional | Update DTOs |
Required<T> | All props required | Validated forms |
Readonly<T> | All props readonly | Immutable config |
Record<K,V> | Dict with typed keys | Lookup tables |
Pick<T,K> | Subset of props | View models |
Omit<T,K> | Remove props | Public interfaces |
Exclude<T,U> | Remove union members | Filtering types |
Extract<T,U> | Keep union members | Filtering types |
NonNullable<T> | Remove null/undefined | After null checks |
ReturnType<F> | Function return type | Infer from impl |
Parameters<F> | Function param tuple | Wrapping functions |
Awaited<T> | Unwrap Promise | Async return types |
InstanceType<C> | Class instance type | Factory 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-flag | What it catches | Impact |
|---|---|---|
strictNullChecks | Forgetting to handle null/undefined | High |
noImplicitAny | Parameters/variables with inferred any | High |
strictFunctionTypes | Unsafe callback parameter types | Medium |
strictBindCallApply | Wrong types passed to bind/call/apply | Medium |
strictPropertyInitialization | Class props not initialised in constructor | Medium |
noImplicitThis | this with implicit any type | Medium |
useUnknownInCatchVariables | catch clause variable typed unknown, not any | Low |
alwaysStrict | Emits "use strict" in every file | Low |
// 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