TypeScript — Engineering
Notes for Typed Code
A working reference for the TypeScript type system as it shows up in real codebases — inference first, then the generics, narrowing, conditional and mapped types that make a large codebase safe to change.
TypeScript is JavaScript plus a static type system that is fully erased at build time. Nothing about types exists at runtime — tsc checks your code and emits plain JavaScript. Every valid JS file is a valid TS file; you adopt types incrementally.
$ npm i -D typescript $ npx tsc --init # creates tsconfig.json $ npx tsc # type-check + emit $ npx tsc --noEmit # check only, emit nothing
Think of the type system as a separate program that runs at compile time and proves your value-level program never does anything contradictory. When the two disagree, the type program wins the argument before your code ever ships.
tsc --noEmit in CI even if your bundler (esbuild, swc, Vite) strips types itself — those tools transpile without type-checking, so without a separate tsc gate the types are decorative.let s: string = "hi"; let n: number = 42; // no int/float distinction let b: boolean = true; let big: bigint = 9007199254740993n; let sym: symbol = Symbol(); let u: undefined = undefined; let nl: null = null; // any — opt out of checking (avoid) // unknown — safe any: must narrow before use // never — the type with no values (unreachable) // void — a function returns nothing meaningful
let xs: number[] = [1, 2, 3]; let pair: [string, number] = ["a", 1]; // tuple let user: { id: number; name?: string } = { id: 1 }; // name? — optional. readonly id — immutable property.
any disables type-checking for everything it touches and spreads silently. Prefer unknown when a value's type is genuinely not known — it forces you to narrow before use.TypeScript infers the type of any initialised binding. Over-annotating is noise. The rule that scales: annotate function parameters and public return types and module boundaries; let everything internal be inferred.
const count = 0; // inferred: number const names = ["a", "b"]; // inferred: string[] // annotate the boundary, infer the body function total(xs: number[]): number { return xs.reduce((a, x) => a + x, 0); }
const route = { path: "/x", method: "GET" } as const; // route.method is the literal "GET", not string const cfg = { port: 3000 } satisfies Record<string, number>; // satisfies checks the shape WITHOUT widening cfg's type
interface Animal { name: string; legs: number; } interface Dog extends Animal { breed: string; } interface Repo<T> { get(id: string): Promise<T | null>; save(x: T): Promise<void>; }
Both describe object shapes. Interfaces support declaration merging and read better in errors; type aliases can express unions, tuples and mapped/conditional types that interfaces cannot. Practical rule: interfaces for public object contracts, type aliases for everything else.
type Id = string | number; // either type Staff = Person & { role: string }; // both type Result<T> = | { ok: true; value: T } | { ok: false; error: string };
function greet(name: string, title = "friend", ...tags: string[]): string { return `${title} ${name} ${tags.join(" ")}`; } // Function type alias type BinOp = (a: number, b: number) => number; const add: BinOp = (a, b) => a + b; // params inferred
function len(x: string): number; function len(x: unknown[]): number; function len(x: string | unknown[]) { return x.length; } // callers see only the two specific signatures
function first<T>(xs: T[]): T | undefined { return xs[0]; } // constrain T to things with a length function longest<T extends { length: number }>(a: T, b: T): T { return a.length >= b.length ? a : b; }
Generic parameters are usually inferred from arguments — you rarely write them at the call site. Provide defaults (<T = string>) for ergonomic APIs, and use keyof + indexed access to relate parameters to each other:
function prop<T, K extends keyof T>(o: T, k: K): T[K] { return o[k]; // return type tracks the exact key }
class Account { private #balance = 0; // true private (runtime) constructor(public readonly owner: string) {} // param property deposit(n: number) { this.#balance += n; } get balance() { return this.#balance; } }
private is compile-time only. For runtime privacy use the JS #field syntax — it can't be accessed from outside the class even via bracket notation.enum Dir { Up, Down } // numeric, reverse-mapped const enum Log { Info = "info" } // inlined, no runtime object
type Dir = "up" | "down"; // no runtime cost, narrows cleanly const DIRS = ["up", "down"] as const; type Dir2 = (typeof DIRS)[number]; // derive the union from data
as const array gives you the type AND an iterable list with one source of truth — enums force you to maintain two.function f(x: string | number | null) { if (x == null) return; // null & undefined if (typeof x === "string") x.trim(); // typeof else x.toFixed(2); // x is number here } // user-defined type guard function isCat(a: Animal): a is Cat { return "purr" in a; }
| Utility | Does |
|---|---|
Partial<T> | all properties optional |
Required<T> | all properties required |
Readonly<T> | all properties readonly |
Pick<T, K> | keep only keys K |
Omit<T, K> | drop keys K |
Record<K, V> | object with keys K, values V |
Returntype<F> | a function's return type |
Awaited<P> | unwrap a Promise |
type Nullable<T> = { [K in keyof T]: T[K] | null }; // key remapping + modifiers type Getters<T> = { [K in keyof T as `get${string & K}`]: () => T[K] }; // -readonly / -? remove the modifiers
type ElementOf<T> = T extends (infer U)[] ? U : never; type A = ElementOf<string[]>; // string // distributive over unions type NonNull<T> = T extends null | undefined ? never : T;
NonNull<string | null> becomes NonNull<string> | NonNull<null> → string. Wrap in a tuple [T] to switch distribution off.type Shape = | { kind: "circle"; r: number } | { kind: "rect"; w: number; h: number }; function area(s: Shape): number { switch (s.kind) { case "circle": return Math.PI * s.r ** 2; case "rect": return s.w * s.h; } const _exhaustive: never = s; // compile error if a case is missed return _exhaustive; }
never exhaustiveness check turns "I added a new variant and forgot to handle it" from a production bug into a compile error.type Event = "click" | "hover"; type Handler = `on${Capitalize<Event>}`; // "onClick" | "onHover" type Route = `/${string}`; // any path starting with /
import type { User } from "./types"; // erased entirely import { type User, save } from "./api"; // mixed export type { User };
import type for anything used only in type position. It guarantees the import is erased — preventing accidental runtime cycles and keeping bundles lean.// globals.d.ts declare global { interface Window { __APP_VERSION__: string; } } // module-shim.d.ts — type an untyped package declare module "legacy-lib" { export function doThing(x: number): string; }
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"noEmit": true,
"skipLibCheck": true
}
}noUncheckedIndexedAccess is off in the default strict set but catches a huge class of "undefined is not a function" bugs from array/record access. Turn it on for new projects.async function load(id: string): Promise<User> { const r = await fetch(`/u/${id}`); if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() as Promise<User>; } type U = Awaited<ReturnType<typeof load>>; // User
- Add
tsconfig.jsonwithallowJs+checkJs: false— TS compiles JS but doesn't check it yet - Rename leaf files
.js → .tsone at a time; fix errors locally - Turn on
strictper-directory via separate tsconfigs, or file-by-file with// @ts-check - Treat
anyas tech debt — grep for it in review; ratchet the count down, never up
unknown at the edges is far safer than one papered over with any to make the build green.| Operator | Meaning |
|---|---|
keyof T | union of T's keys |
T[K] | indexed access (value type at K) |
typeof v | the type of a value |
T extends U ? X : Y | conditional type |
infer R | capture a type inside a conditional |
{ [K in keyof T]: ... } | mapped type |
as const | literal, deeply readonly |
x satisfies T | check shape without widening |
x as T | assertion (escape hatch — last resort) |
x! | non-null assertion |