Search Tech Journey

Find topics, journeys and posts

back to blog
system designintermediate 30m2026-06-10

MS Stack Ch 7 — TypeScript

Structural typing, narrowing, generics, discriminated unions, utility types, tsconfig. TypeScript without the over-engineering.

Chapter 7 of From Novice to Fluent on the Modern Microsoft Web Stack — a 22-chapter self-study plan.

Why this chapter

TypeScript is not a different language. It is JavaScript with a type checker stapled to the front of the build. At runtime, the types are gone — every .ts file compiles down to plain JavaScript and the runtime behaviour is identical. The value is entirely in the editing-and-CI phase: red squiggles in your IDE, broken builds in CI, automated refactors that don't miss a call site, and JSDoc-grade documentation that cannot lie because the compiler enforces it.

The shipping-vs-expert gap on TypeScript is the widest in this whole stack. The shipping engineer types every function's parameters as : any, casts with as whenever the compiler complains, and considers TypeScript a tax. The expert leans on inference (you don't need to write the types — TypeScript will figure most of them out), reaches for discriminated unions and as const to make illegal states unrepresentable, knows when not to use a generic (when one specific type is enough), and treats any and as as smells to be justified in a code review. The expert's code has fewer types than the shipping engineer's, and stricter ones.

You finish this chapter when you can read any TypeScript codebase without reaching for the docs, write a discriminated union that turns an entire class of bugs into compile errors, and configure tsconfig.json from scratch with strict mode on and no noImplicitAny escape hatches.

Concepts and depth

Why TypeScript over JavaScript — structural typing

The pitch is simple: catch bugs at compile time that would otherwise hit you at runtime, give your IDE enough information to autocomplete and refactor reliably, and make your code self-documenting. A function signature like function getUser(id: UserId): Promise<User | null> tells a reader (and the compiler) far more than function getUser(id). When you rename a field on User, every call site updates atomically; when you remove one, every consumer breaks at compile time instead of at 3am in production.

TypeScript uses structural typing (a.k.a. duck typing) — two types are compatible if their shapes are compatible, regardless of name. Coming from Java or C#, this is the biggest mindset shift: in TS, interface Cat { name: string } and interface Dog { name: string } are interchangeable because they have the same shape. This is great (it eliminates pointless wrapper types) and dangerous (you can accidentally pass a Cat where a Dog is expected). The escape hatch when you want nominal typing is the branded type pattern: type UserId = string & { readonly _brand: 'UserId' }. Now plain strings can't be passed where UserId is required.

The shipping engineer's first instinct is to fight the type checker. The expert's first instinct is to listen to it. When the type checker complains, 90% of the time it has surfaced a real bug — the missing null check, the typo in a field name, the wrong order of arguments. The remaining 10% of the time, it's revealing that your type model is wrong and needs to be more precise (not laxer).

Good enough to ship
  • • Annotate function params and returns
  • • Run tsc --noEmit in CI
  • • Never use any unless wrapping untyped code briefly
Expert tier
  • • Brand string IDs to enforce nominal-ish typing
  • • Lean on inference; type only the boundaries
  • • Read d.ts files to understand library guarantees

Primitive types, literal types, union, intersection

The primitives: string, number, boolean, bigint, symbol, null, undefined. null and undefined are distinct (under strictNullChecks, which you always have on) — a string | null is not the same as a string | undefined, and the difference matters when interfacing with JSON (always null) vs object property access (often undefined).

Literal types narrow a primitive to a single value: type Status = 'idle' | 'loading' | 'success' | 'error' is a union of four string literal types. This is how you model finite sets of values — far better than string plus a runtime check. Template literal types (type EventName = `on${Capitalize<Event>}` ) let you compose string literal types programmatically and are how libraries like react-router type their route parameters.

Union (A | B) means "either A or B"; intersection (A & B) means "both A and B simultaneously" (the result has every property of both). Unions are common in domain modelling (a Result is either Success or Failure); intersections are common in composition (User & WithTimestamps adds createdAt / updatedAt to a user type). Union members can be narrowed with type guards; intersection members all coexist.

type Color = 'red' | 'green' | 'blue';
type RGB = `#${string}`;                       // any string starting with #
type CssColor = Color | RGB;

type User = { id: string; name: string };
type Audit = { createdAt: Date; updatedAt: Date };
type StoredUser = User & Audit;                // intersection — has all four fields
Good enough to ship
  • • Use string literal unions for finite sets
  • • Know null vs undefined difference under strict
  • • Reach for union for "or", intersection for "and"
Expert tier
  • • Template literal types for typed URL params, CSS variables, event names
  • • Reason about union distribution in conditional types
  • • Use bigint where Number precision is insufficient

Interfaces vs type aliases — when each

interface Foo { ... } and type Foo = { ... } overlap heavily. Both describe object shapes; both participate in structural typing; both can be extended/intersected. The differences:

  • interface is open — you can declare it twice and TypeScript merges the declarations (declaration merging). This is how @types/* packages augment third-party types and how you add custom properties to window or Request.
  • type is closed — re-declaring is an error.
  • type can express anything — unions, intersections, conditional types, mapped types, primitives, tuples. interface can only describe object shapes (and function call signatures).
  • interface extends is slightly faster for the compiler than type & type on hot paths (rare to matter).

Rule of thumb: use interface for public object-shape APIs you want consumers to be able to augment (props of a React component library, the shape of window); use type for everything else (unions, mapped types, intersections, domain models, internal shapes). Pick one and be consistent within a file — the bikeshed isn't worth the diff churn.

// public, augmentable
export interface ButtonProps {
  variant?: 'primary' | 'secondary';
  onClick?: () => void;
}

// internal, expressive
type Result<T> = { ok: true; value: T } | { ok: false; error: Error };
Good enough to ship
  • • Use either, pick one per file
  • • Use interface for component props if publishing a library
  • • Use type for unions, mapped types, primitives
Expert tier
  • • Use declaration merging to augment Request, Window, JSX.IntrinsicElements
  • • Know perf nuance of interface extends vs type intersection
  • • Audit which exported types are accidentally part of your public API

Generics, conditional types, mapped types (awareness)

A generic is a type with parameters. function identity<T>(x: T): T { return x; } says "for any T, this function takes a T and returns a T". Generics let you write code that is type-safe across many concrete types without copy-pasting. The most common forms: functions (function map<A, B>(arr: A[], fn: (a: A) => B): B[]), classes (class Stack<T> { push(x: T) {} }), and types (type Result<T, E = Error> = ...).

Constraints (<T extends { id: string }>) limit what types T can be. Defaults (<T = string>) provide a fallback when the caller doesn't specify. Inference is what makes generics ergonomic — you almost never write identity<number>(42); you write identity(42) and TS infers T = number. The art of writing good generic APIs is letting inference do the work: position type parameters so callers don't need to specify them explicitly.

Conditional types (T extends U ? X : Y) branch on a type. They are the basis of every "if T is an array, give me its element type" trick: type Element<T> = T extends (infer U)[] ? U : never. The infer keyword binds a type variable inside the branch. Conditional types distribute over naked union type parameters: type NonNullable<T> = T extends null | undefined ? never : T — passed string | null | undefined, it runs three times and unions the results.

Mapped types (type Partial<T> = { [K in keyof T]?: T[K] }) iterate over an object type's keys and produce a new type. They are how every utility type below is built. You won't write mapped types daily, but reading library typings becomes much easier when you can recognise the pattern.

// Generic with constraint and inference
function byId<T extends { id: string }>(items: T[], id: string): T | undefined {
  return items.find(x => x.id === id);
}

// Conditional type with infer
type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T;

// Mapped type
type ReadonlyDeep<T> = { readonly [K in keyof T]: ReadonlyDeep<T[K]> };
Good enough to ship
  • • Write generic functions with constraints
  • • Trust inference; only annotate when it's wrong
  • • Recognise Awaited, Partial, Pick in libraries
Expert tier
  • • Write conditional types with infer
  • • Use distribution over unions intentionally
  • • Author mapped types with key remapping (as clause)

unknown vs any vs never

any opts out of type checking entirely — values flow in and out without any constraint. Reach for it briefly when wrapping untyped third-party code; otherwise it's a code smell. Modern lint rules (@typescript-eslint/no-explicit-any) flag it for a reason.

unknown is the type-safe any. A value of type unknown accepts anything (like any) but you cannot do anything with it without narrowing first. This is the right type for JSON.parse results, fetch().json() returns, dynamic message payloads, and any boundary where data comes from outside your type system. Pair it with a runtime validator (Zod, valibot, ArkType) to narrow unknown to a real shape.

never is the type with no values. It appears as the return type of functions that never return (throw, infinite loop, process.exit), as the type of a variable in an exhausted narrowing branch (the all-roads-checked default of a switch), and as the result of an impossible intersection (string & number). The classic use is the exhaustiveness check: in the default branch of a discriminated-union switch, assert that the value is never. If you add a new case to the union and forget to handle it, the compiler errors out.

function parseConfig(raw: unknown): Config {
  // unknown forces you to validate before using
  if (typeof raw !== 'object' || raw === null) throw new Error('not an object');
  // ... validate fields, then return as Config
  return raw as Config;
}

function assertNever(x: never): never {
  throw new Error(`unexpected: ${JSON.stringify(x)}`);
}
Good enough to ship
  • • unknown for parsed/external data, then narrow
  • • Avoid any; lint to enforce
  • • Use never as an exhaustiveness check
Expert tier
  • • Pair unknown with Zod schemas for runtime + compile-time safety
  • • Use never to model impossible states in domain types
  • • Distinguish any (escape hatch) from (anything non-nullish)

Type narrowing — typeof, in, instanceof, type guards, discriminated unions

Narrowing is how TypeScript follows control flow to refine a variable's type within a branch. The built-in narrowing operators:

  • typeof x === 'string' narrows to string. Works for primitives.
  • x instanceof Date narrows to Date. Works for class instances.
  • 'field' in x narrows to the variant of the union that has field.
  • Truthiness: if (user) narrows out null | undefined | 0 | '' | false.
  • Equality: if (status === 'idle') narrows to that literal.

A user-defined type guard is a function whose return type is a type predicate (x is Foo). The compiler trusts your judgement inside that function and narrows accordingly outside. Use these to centralise narrowing logic.

function isError(x: unknown): x is Error {
  return x instanceof Error || (typeof x === 'object' && x !== null && 'message' in x);
}

try { /* ... */ }
catch (e) {
  if (isError(e)) console.error(e.message); // e is narrowed to Error
}

Discriminated unions are the killer pattern. Give each variant a literal-typed discriminant field (commonly kind or type), and TS narrows on switch (x.kind) cases automatically. This makes "what fields exist on which variant" type-safe end-to-end.

type LoadState<T> =
  | { kind: 'idle' }
  | { kind: 'loading' }
  | { kind: 'success'; data: T }
  | { kind: 'error'; error: Error };

function render(state: LoadState<User[]>) {
  switch (state.kind) {
    case 'idle':    return <Empty />;
    case 'loading': return <Spinner />;
    case 'success': return <List items={state.data} />;
    case 'error':   return <Error message={state.error.message} />;
  }
}
Good enough to ship
  • • Use typeof, instanceof, in for built-in narrowing
  • • Write type guards for repeated checks
  • • Use discriminated unions for state machines
Expert tier
  • • Assertion functions (asserts x is Foo)
  • • Narrowing across functions via control-flow analysis limits
  • • Combine discriminated unions with as const for inference-driven state machines

as const and inferred literal types

By default, TypeScript widens literal values to their primitive type. const x = 'hello' infers string; const point = { x: 1, y: 2 } infers { x: number; y: number }. This is usually what you want for assignment. It is rarely what you want when you're defining a configuration object whose literal values matter.

as const (a const assertion) suppresses widening: const point = { x: 1, y: 2 } as const infers { readonly x: 1; readonly y: 2 }. Combined with array literals, it produces tuple types: ['idle', 'loading', 'success'] as const is readonly ['idle', 'loading', 'success']. Combined with a typeof/number indexed access, it gives you a union type derived from a value: type Status = (typeof STATUSES)[number].

This pattern is gold for keeping a single source of truth between runtime and compile time. You define a list once at runtime, and the type system derives the union type for you — no copy-paste, no drift.

const STATUSES = ['idle', 'loading', 'success', 'error'] as const;
type Status = (typeof STATUSES)[number]; // 'idle' | 'loading' | 'success' | 'error'

const ROUTES = {
  home: '/',
  dashboard: '/dashboard',
  user: '/users/:id',
} as const;
type RouteName = keyof typeof ROUTES;        // 'home' | 'dashboard' | 'user'
type RoutePath = (typeof ROUTES)[RouteName]; // '/' | '/dashboard' | '/users/:id'
Good enough to ship
  • • Use as const for configuration arrays/objects
  • • Derive union types from value arrays
  • • Know it makes results readonly
Expert tier
  • • Build "satisfies" expressions to constrain shape without widening
  • • Pair as const with template literal types for typed URLs
  • • Avoid as const on hot paths that mutate (it freezes the type)

Utility types — Partial, Required, Pick, Omit, Record, ReturnType, Awaited

The standard library of types ships in lib.es*.d.ts and is worth memorising:

  • Partial<T> — all properties optional. Use for update payloads (updateUser(id, patch: Partial<User>)).
  • Required<T> — all properties required (strips ?).
  • Readonly<T> — all properties readonly.
  • Pick<T, K> — keep only the keys listed in K. Pick<User, 'id' | 'name'> for a slim DTO.
  • Omit<T, K> — drop the keys listed in K. Omit<User, 'password'> for a safe response shape.
  • Record<K, V> — an object whose keys are K and values are V. Record<UserId, User> for a lookup map.
  • ReturnType<F> — the return type of a function. ReturnType<typeof getUser> keeps types in sync as you refactor.
  • Parameters<F> — the tuple of parameter types of a function.
  • Awaited<P> — the resolved type of a promise (recursively). Awaited<Promise<Promise<T>>> is T.
  • NonNullable<T> — strips null | undefined from a union.
  • Exclude<T, U> / Extract<T, U> — filter a union by membership.

Compose them. Partial<Pick<User, 'name' | 'email'>> is a patch shape with just two optional fields. Omit<User, keyof Audit> is User without timestamps. Record<Status, string> is a label map for every status. Once these are reflexes, you stop hand-writing type definitions that the compiler can derive.

type User = { id: string; name: string; email: string; password: string; createdAt: Date };

type UserResponse = Omit<User, 'password'>;
type UserPatch    = Partial<Pick<User, 'name' | 'email'>>;
type UserMap      = Record<string, UserResponse>;
type LoadUser     = (id: string) => Promise<UserResponse>;
type LoadUserOut  = Awaited<ReturnType<LoadUser>>; // UserResponse
Good enough to ship
  • • Reach for Partial, Pick, Omit, Record daily
  • • Use ReturnType to derive types from functions
  • • Compose utility types instead of hand-writing variants
Expert tier
  • • Write your own DeepPartial, DeepReadonly, NoInfer wrappers
  • • Use Exclude/Extract to filter discriminated unions
  • • Use NoInfer (TS 5.4+) to control inference site

Enums, const enums, and why string literal unions usually win

TypeScript inherited enum from C#-flavoured ancestry. It comes in two flavours: numeric (the default, members are auto-numbered) and string (every member must have an explicit string value). Both emit runtime objects, which means they show up in your bundle, can't be tree-shaken if any member is used, and — for numeric enums — also create a reverse mapping (you can do Status[0] and get 'idle'). That bidirectionality sounds cute and is almost never useful.

The modern idiomatic replacement is a string literal union derived from an as const array or object. You get the same compile-time exhaustiveness, zero runtime cost, full tree-shaking, and the values are just strings in network payloads and logs — no more debugging "status":2 in JSON. const enum (compile-time inlined) exists but is incompatible with isolatedModules: true, which every modern bundler (Vite, esbuild, swc) requires; you cannot rely on it.

When do you actually need enum? Two cases: interop with auto-generated code from a backend (gRPC, OpenAPI generators) that emits enums, and migrating a large existing codebase where the diff is not worth it. New code: string literal union, every time.

// Don&apos;t
enum Status { Idle, Loading, Success, Error }

// Do
const STATUSES = ['idle', 'loading', 'success', 'error'] as const;
type Status = (typeof STATUSES)[number];
Good enough to ship
  • • Prefer string literal unions to enums
  • • If using enum, use string-valued (not numeric)
  • • Avoid const enum with isolatedModules bundlers
Expert tier
  • • Migrate existing enums to as const objects without breaking call sites
  • • Inspect the JS that enum actually emits
  • • Generate types from OpenAPI/proto without leaking enums to app code

The satisfies operator

TypeScript 4.9 added satisfies, and it changed how you write configuration. The old dilemma: annotate a const to enforce a shape but lose the precise inferred types, or skip the annotation and risk a typo. satisfies gives you both — it checks that the value conforms to a type without widening the inferred type.

type RouteConfig = Record<string, { path: string; auth?: boolean }>;

// With annotation — loses the precise keys
const routes1: RouteConfig = {
  home: { path: '/' },
  admin: { path: '/admin', auth: true },
};
// routes1.home is { path: string; auth?: boolean } — lost the literal '/'

// With satisfies — keeps precise types AND validates shape
const routes2 = {
  home: { path: '/' },
  admin: { path: '/admin', auth: true },
} satisfies RouteConfig;
// routes2.home.path is '/' (literal)
// routes2.admin.auth is true (literal)
// typing 'hime' would be a compile error

Reach for satisfies on every config object, every Tailwind theme, every chart options literal. It eliminates the most common annotation regret in modern TS code.

Good enough to ship
  • • Use satisfies on config objects to preserve literal types
  • • Prefer satisfies + as const over plain annotation for constants
  • • Know the rule: satisfies validates without widening
Expert tier
  • • Combine satisfies with mapped types to validate record shapes
  • • Use satisfies for typed Zod-like DSL configs
  • • Audit codebases for unnecessary annotations that lost inference

tsconfig.json — settings that matter

tsconfig.json is where most "why doesn't my code compile" stories begin. The settings worth understanding:

  • strict: true — enables noImplicitAny, strictNullChecks, strictFunctionTypes, strictPropertyInitialization, noImplicitThis, useUnknownInCatchVariables, alwaysStrict. Turn it on. New code only.
  • target — the JS version to emit (ES2022, ESNext). Affects downlevelling (async/await, optional chaining, class fields).
  • module — module output (ESNext for ESM, CommonJS for legacy Node). With NodeNext you get Node's ESM/CJS dual-resolution.
  • moduleResolutionBundler for Vite/Next/esbuild stacks; NodeNext for raw Node.
  • jsxreact-jsx (the new automatic runtime, no need to import React) for React 17+; preserve if a bundler handles JSX downstream (most setups).
  • paths — alias @/* to src/* so you write import { Button } from '@/components/Button' instead of '../../../../components/Button'. Pair with baseUrl: '.'.
  • noEmit: true — when a bundler (Vite, Next) handles the actual JS emit; TS is just a checker.
  • skipLibCheck: true — skip checking node_modules .d.ts files. Almost always on; speeds up the build dramatically.

A minimal modern config:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "jsx": "react-jsx",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noFallthroughCasesInSwitch": true,
    "exactOptionalPropertyTypes": true,
    "noEmit": true,
    "skipLibCheck": true,
    "baseUrl": ".",
    "paths": { "@/*": ["src/*"] }
  },
  "include": ["src"]
}

The three "strict-plus" flags worth turning on once strict is comfortable: noUncheckedIndexedAccess (arr[0] is T | undefined, not T), exactOptionalPropertyTypes (distinguishes "missing" from "explicitly undefined"), noFallthroughCasesInSwitch (errors on accidental switch fallthrough).

Good enough to ship
  • • strict: true from day one on new projects
  • • target ES2022, module ESNext, moduleResolution Bundler
  • • paths alias for clean imports
Expert tier
  • • Enable noUncheckedIndexedAccess and exactOptionalPropertyTypes
  • • Use project references (composite) for monorepos
  • • Tune incremental + tsBuildInfoFile for CI cache hits

Type-only imports, isolated modules, and verbatimModuleSyntax

A TypeScript file routinely imports things that exist only at the type level: an interface, a type alias, the type of a value (import type { User } from '@/types'). These imports must not produce a runtime require/import, or you'll get "cannot find module 'X' at runtime" when the type-only file is excluded from the build. TypeScript's import type and export type syntax marks these explicitly. The compiler enforces it under isolatedModules: true (required by Vite/esbuild/swc) and especially under verbatimModuleSyntax: true (TS 5.0+), which refuses to elide imports it isn't sure are type-only.

The pragmatic rule: whenever you import a type, write import type {...}. Whenever you import a value, write a plain import {...}. When you need both, use import { X, type Y } from '...'. ESLint's consistent-type-imports rule auto-fixes this on save and saves you the cycle.

import type { User } from './types';                 // type-only — erased
import { loadUser, type LoadOptions } from './api';  // value + type in one line
Good enough to ship
  • • Use import type for type-only imports
  • • Enable consistent-type-imports lint rule
  • • isolatedModules on for Vite/esbuild builds
Expert tier
  • • Enable verbatimModuleSyntax to surface incorrect type-only assumptions
  • • Avoid circular value imports by splitting type vs value modules
  • • Audit bundle for type-only modules accidentally bundled

Worked examples

Example 1 — discriminated-union state machine for a fetch

Model the four states of an async data fetch as a discriminated union and write a render function that the compiler proves exhaustive.

type AsyncState<T, E = Error> =
  | { kind: 'idle' }
  | { kind: 'loading' }
  | { kind: 'success'; data: T }
  | { kind: 'error'; error: E };

function reducer<T>(_: AsyncState<T>, action: AsyncState<T>): AsyncState<T> {
  return action;
}

function assertNever(x: never): never {
  throw new Error(`unreachable: ${JSON.stringify(x)}`);
}

function describe<T>(s: AsyncState<T>): string {
  switch (s.kind) {
    case 'idle':    return 'waiting to start';
    case 'loading': return 'fetching…';
    case 'success': return `loaded ${JSON.stringify(s.data).length} bytes`;
    case 'error':   return `failed: ${s.error.message}`;
    default:        return assertNever(s);
  }
}
  • The discriminant kind is a string literal — TS narrows fully inside each case.
  • Inside success, s.data is T; inside error, s.error is E. No casting.
  • Add a fifth variant to the union — the compiler errors on assertNever. Exhaustiveness for free.
  • The default fallthrough never runs at runtime; it's a compile-time safety net.

Example 2 — typed environment-variable loader

Read env vars with runtime validation that doubles as compile-time types.

import { z } from 'zod';

const EnvSchema = z.object({
  NODE_ENV: z.enum(['development', 'production', 'test']),
  PORT: z.coerce.number().int().positive().default(3000),
  DATABASE_URL: z.string().url(),
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});

export type Env = z.infer<typeof EnvSchema>;

function loadEnv(raw: Record<string, string | undefined>): Env {
  const parsed = EnvSchema.safeParse(raw);
  if (!parsed.success) {
    console.error('Invalid environment:', parsed.error.flatten().fieldErrors);
    process.exit(1);
  }
  return parsed.data;
}

export const env = loadEnv(process.env);
// env.PORT is number, env.NODE_ENV is the literal union, etc.
  • z.infer<typeof EnvSchema> derives the TS type from the Zod schema — single source of truth.
  • z.coerce.number() converts the string env var to a number at parse time.
  • safeParse returns a tagged result; we crash with a readable error if anything is missing or wrong.
  • Downstream code reads env.X with full type narrowing and no runtime checks.

Example 3 — branded UserId vs OrgId

Prevent passing the wrong ID to a function that expects a specific one.

type Brand<T, B> = T & { readonly __brand: B };

export type UserId = Brand<string, 'UserId'>;
export type OrgId  = Brand<string, 'OrgId'>;

export const UserId = (s: string) => s as UserId;
export const OrgId  = (s: string) => s as OrgId;

function loadUser(id: UserId): Promise<User> { /* ... */ }

const u = UserId('u_123');
const o = OrgId('o_456');

await loadUser(u);     // ✅
await loadUser(o);     // ❌ Type 'OrgId' is not assignable to 'UserId'
await loadUser('raw'); // ❌ Type 'string' is not assignable to 'UserId'
  • The brand is phantom__brand doesn't exist at runtime; it's pure type-level marker.
  • Constructor functions are the only way to create branded values, centralising validation.
  • At runtime, branded types are just strings — zero overhead.
  • This is the closest TS gets to nominal typing without giving up structural typing globally.

Example 4 — typed event bus with discriminated payloads

Pub/sub with one event union; subscribers get strongly typed payloads per event.

type AppEvent =
  | { type: 'user/login'; userId: string }
  | { type: 'user/logout' }
  | { type: 'cart/add'; sku: string; qty: number }
  | { type: 'cart/clear' };

type EventMap = { [E in AppEvent as E['type']]: E };

class Bus {
  private handlers: { [K in keyof EventMap]?: Array<(e: EventMap[K]) => void> } = {};

  on<K extends keyof EventMap>(type: K, fn: (e: EventMap[K]) => void): () => void {
    (this.handlers[type] ??= []).push(fn as never);
    return () => {
      this.handlers[type] = this.handlers[type]?.filter(h => h !== fn) as never;
    };
  }

  emit<K extends keyof EventMap>(type: K, payload: Omit<EventMap[K], 'type'>) {
    const e = { type, ...payload } as EventMap[K];
    this.handlers[type]?.forEach(fn => (fn as (x: EventMap[K]) => void)(e));
  }
}

const bus = new Bus();
bus.on('cart/add', (e) => console.log(e.sku, e.qty)); // e is { type: 'cart/add'; sku: string; qty: number }
bus.emit('cart/add', { sku: 'X1', qty: 2 });
// bus.emit('cart/add', { sku: 'X1' }); // ❌ missing qty
  • A key remapping mapped type (as E['type']) builds the EventMap from the union.
  • Omit<EventMap[K], 'type'> on emit makes the type redundant in the payload.
  • Subscriber callbacks receive the narrowed event variant — TS does the discrimination.
  • Adding a new variant to AppEvent flows through to emit/on automatically.

Classes, abstract classes, and decorators (awareness)

Classes are a runtime feature TypeScript layers types on. Modern web/backend TS code uses classes sparingly — you reach for them for stateful services (a Logger, a Cache, a NestJS controller), wrapper objects around imperative libraries, and ORMs that depend on them. Plain functions + closures cover most of what classes used to.

The TS-specific class features worth knowing: parameter properties (constructor(private logger: Logger) declares and assigns the field in one line); access modifiers (public, private, protected — enforced at compile time, not runtime; for runtime privacy use #field); readonly fields; abstract classes and methods (a base class consumers must extend; abstract members must be implemented). Decorators (TC39 Stage 3, supported in TS 5.0+) annotate classes, methods, and fields — they show up in Angular, NestJS, MikroORM. Modern decorators (different from the legacy experimentalDecorators flavour) are stable enough to use in new code if your framework supports them.

Class-based code interacts oddly with structural typing: a class Foo { private x = 1 } and class Bar { private x = 1 } are not compatible (TS treats private as a brand). Two classes with only public members and the same shape are compatible. This is one of the few places TS leans nominal.

abstract class HttpClient {
  constructor(protected readonly baseUrl: string) {}
  abstract get<T>(path: string): Promise<T>;
}

class FetchClient extends HttpClient {
  async get<T>(path: string): Promise<T> {
    const res = await fetch(`${this.baseUrl}${path}`);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return (await res.json()) as T;
  }
}
Good enough to ship
  • • Parameter properties for terse constructors
  • • readonly for immutable state
  • • #field for true runtime privacy
Expert tier
  • • Use modern decorators where your framework supports them
  • • Know abstract + protected as DI-friendly contracts
  • • Avoid class hierarchies more than two levels deep

Example 5 — inferring API response types with satisfies and a typed router

A tiny typed-router pattern: routes declared as a single config object, the type derived automatically, and the loader function for each route inferred to match.

type Loader<P> = (params: P) => Promise<unknown>;

const routes = {
  '/users':              { loader: async () => fetchUsers() },
  '/users/:id':          { loader: async ({ id }: { id: string }) => fetchUser(id) },
  '/orgs/:orgId/users':  { loader: async ({ orgId }: { orgId: string }) => fetchOrgUsers(orgId) },
} satisfies Record<string, { loader: Loader<any> }>;

type RoutePath = keyof typeof routes;
type RouteParams<P extends RoutePath> = Parameters<typeof routes[P]['loader']>[0];
type RouteData<P extends RoutePath>   = Awaited<ReturnType<typeof routes[P]['loader']>>;

async function load<P extends RoutePath>(path: P, params: RouteParams<P>): Promise<RouteData<P>> {
  return routes[path].loader(params as never) as Promise<RouteData<P>>;
}

const u = await load('/users/:id', { id: 'u_1' });
// u is whatever fetchUser returns — fully inferred
  • satisfies keeps the precise loader types per route while still validating the shape.
  • RouteParams<P> and RouteData<P> derive the parameter and result type for each path.
  • The single load function is fully type-safe per route at every call site.
  • Adding a new route extends every type automatically.

Hands-on exercises

  1. Replace any with unknown. Goal: find every any in a real codebase and replace it with a properly narrowed type.

    1. Grep for : any and as any across the repo.
    2. For each match, decide: is this parsed external data (use unknown + validator), a temporary escape hatch (delete, write the real type), or legitimate dynamic (rare; keep with a comment explaining)?
    3. Add a lint rule (@typescript-eslint/no-explicit-any) to prevent regression.
    4. Run tsc --noEmit and fix any new errors.
    5. You're done when: zero any (or each remaining one has a // eslint-disable comment with justification).
  2. Discriminated-union refactor. Goal: turn a "boolean-soup" object into a discriminated union.

    1. Find an object that has isLoading, isError, error?, data? shapes (it's everywhere).
    2. Rewrite it as a LoadState<T> union with kind: 'idle' | 'loading' | 'success' | 'error'.
    3. Update every render site to switch on kind.
    4. Add an assertNever default case.
    5. You're done when: impossible states (isLoading: true, data: ...) are no longer expressible.
  3. Branded IDs. Goal: prevent passing the wrong ID type to a function.

    1. Define brands for UserId, OrgId, ProjectId.
    2. Replace all parameter types id: string with the specific branded ID.
    3. Add constructor functions (UserId(raw)) and use them at API/DB boundaries.
    4. Run the build; fix every error by adding the brand at the source.
    5. You're done when: swapping a UserId and OrgId at any call site is a compile error.
  4. Derive types from values. Goal: eliminate type drift between runtime constants and their union types.

    1. Find a hardcoded type Status = 'a' | 'b' | 'c' and a runtime const STATUSES = ['a', 'b', 'c'].
    2. Rewrite as const STATUSES = [...] as const and type Status = (typeof STATUSES)[number].
    3. Add a new status — confirm types and runtime stay in sync without a second edit.
    4. Repeat for every routes/permissions/roles enum-like object.
    5. You're done when: there is no place in the repo where you'd need to update both a value and a type when adding a member.
  5. Strict-plus migration. Goal: enable two additional strict flags on a project.

    1. Add noUncheckedIndexedAccess: true to tsconfig.json. Run tsc --noEmit.
    2. Fix all errors (arr[i] is now T | undefined; add null checks or use arr.at(i)?.foo).
    3. Add exactOptionalPropertyTypes: true. Fix the (fewer) errors that come up.
    4. Commit and watch CI catch a previously-silent class of bugs.
    5. You're done when: CI is green with both flags on and you have a follow-up doc about gotchas.
  6. Build a typed event bus. Goal: write the pattern from Worked Example 4 from scratch.

    1. Define an AppEvent discriminated union with at least four event types.
    2. Write on, off, emit with full type inference.
    3. Add a test that proves emitting a wrong payload type is a compile error.
    4. Bonus: add a once method and a wildcard subscriber for logging.
    5. You're done when: every on callback receives a strongly-typed event and emit rejects malformed payloads.
  7. Replace annotations with satisfies. Goal: preserve literal inference on config constants.

    1. Find at least three constants in your codebase annotated with a wide type (Record<string, X>, X[], an interface).
    2. Convert each to } satisfies T (and add as const where you want immutability).
    3. Show, in DevTools / IDE hover, that the inferred type is now more precise than before.
    4. Use one of the now-narrowed types in a downstream lookup that wouldn't have type-checked before.
    5. You're done when: no widening regret remains and one downstream consumer benefits.
  8. Author a small utility type library. Goal: practice mapped + conditional types.

    1. Implement DeepPartial<T>, DeepReadonly<T>, Nullable<T>, Result<T, E>.
    2. Add types-only unit tests (type _Test = Expect<Equal<...>> pattern; copy from @type-challenges/utils).
    3. Wire them into a real domain type and use them.
    4. Add NoInfer<T> (TS 5.4+) to a function that was inferring too aggressively.
    5. You're done when: the library has four utility types, each with a passing type test.
  9. Type a third-party JS library. Goal: write your own .d.ts for a small untyped npm package.

    1. Pick a small no-types package (or use a deliberately untyped local file).
    2. Write a module 'pkg' { ... } declaration in a types/global.d.ts file.
    3. Add it to tsconfig's include.
    4. Verify autocomplete works in a consumer file.
    5. You're done when: the consumer file is fully typed with no @ts-ignore.

Self-check questions

  1. What does "structural typing" mean, and how does it differ from nominal typing?
  2. When would you use interface over type alias?
  3. Difference between unknown, any, and never — and where does each belong?
  4. Write a discriminated union for the four states of an async fetch.
  5. Explain as const. Why does it give you tuple types and readonly properties?
  6. What does the infer keyword do inside a conditional type? Give an example.
  7. What does Partial<Pick<User, 'name' | 'email'>> evaluate to?
  8. Explain the branded-type pattern. Why is it "phantom"?
  9. What is exhaustiveness checking, and how does never enable it?
  10. When does TypeScript widen a literal type, and how do you stop it?
  11. Why does noUncheckedIndexedAccess change arr[0] from T to T | undefined?
  12. Difference between Partial<T> and Readonly<T> and Required<T>.
  13. What does Awaited<Promise<Promise<number>>> evaluate to, and why?
  14. When would you reach for a type guard function vs the built-in in / typeof?
  15. What does satisfies give you that a plain annotation doesn't?
  16. Why is a string-literal union usually preferable to a TypeScript enum?
  17. What problem does noUncheckedIndexedAccess solve, and what costs does it add?

High-signal resources

Official docs

Books or courses

  • Effective TypeScript by Dan Vanderkam (2nd ed., O'Reilly, 2024) — 83 practical rules, the best single book on the language.
  • Total TypeScript by Matt Pocock — interactive exercises, free Beginner's Tutorial.
  • Type-Level TypeScript — for going deep on the type system as a programming language.

Practitioner posts

Weekly milestones

  1. Day 1 — Read the TypeScript Handbook's "Everyday Types" and "Narrowing" chapters. Do Exercise 1.
  2. Day 2 — Read "Type Manipulation" (Generics, Keyof, Mapped, Conditional). Do Exercise 4.
  3. Day 3 — Read tsconfig reference for strict and module flags. Do Exercise 5.
  4. Day 4–5 — Do Exercises 2 and 3. Answer self-check 1-7.
  5. Day 6–7 — Do Exercise 6. Watch one Anders Hejlsberg talk. Answer self-check 8-17.

If you finish early, pick a JS file in a project you use and convert it to TypeScript with strict: true. Notice every place the compiler complains — each of those is a bug or an ambiguity that was there before, silent.

How it shows up in the capstone

The capstone dashboard uses TypeScript end-to-end. Every API request and response is typed with a Zod schema (z.infer derives the TS type), every React component has typed props, every route param is a string literal narrowed by react-router's useParams, every chart series is a discriminated union of 'line' | 'area' | 'column' with variant-specific options. The state of every async fetch is a discriminated union; rendering is a switch on state.kind with an assertNever default — adding a new state breaks the build until every component handles it.

Branded types appear for the four ID kinds (UserId, OrgId, DashboardId, ChartId) so you cannot accidentally pass a dashboard ID to loadChart. Tsconfig is strict-plus from day one — noUncheckedIndexedAccess, exactOptionalPropertyTypes, the works. Build time is acceptable because skipLibCheck: true is on and incremental compilation is wired up in CI.

You will feel this chapter most when you refactor a field on a domain type and the compiler walks you through every call site, one error at a time, instead of leaving a runtime time-bomb. After three months of strict TS, you will not be able to go back to plain JS without flinching.

The second place you will feel it is when you onboard a new engineer. The same codebase that took six months to understand in plain JS is browsable in a week once every function signature, every prop, every event payload tells you exactly what it expects. The IDE becomes a teacher. Hover-for-type, jump-to-definition, find-all-references — they only work because the types are real.

The third place is the bug ticket count. Once strict TS is in CI, a whole class of bug — "undefined is not a function", "cannot read property 'x' of null", "why is this string?" — disappears from production. You stop logging defensive console.warn lines because the type system has already proven the case can't happen.

Next chapter → Ch 8 — Frontend build tooling Previous chapter → Ch 6 — Modern frontend baseline