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).
- • Annotate function params and returns
- • Run
tsc --noEmitin CI - • Never use any unless wrapping untyped code briefly
- • 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
- • Use string literal unions for finite sets
- • Know null vs undefined difference under strict
- • Reach for union for "or", intersection for "and"
- • 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:
interfaceis 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 towindoworRequest.typeis closed — re-declaring is an error.typecan express anything — unions, intersections, conditional types, mapped types, primitives, tuples.interfacecan only describe object shapes (and function call signatures).interface extendsis slightly faster for the compiler thantype & typeon 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 };
- • Use either, pick one per file
- • Use interface for component props if publishing a library
- • Use type for unions, mapped types, primitives
- • 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]> };
- • Write generic functions with constraints
- • Trust inference; only annotate when it's wrong
- • Recognise Awaited, Partial, Pick in libraries
- • 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)}`);
}
- • unknown for parsed/external data, then narrow
- • Avoid any; lint to enforce
- • Use never as an exhaustiveness check
- • 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 tostring. Works for primitives.x instanceof Datenarrows toDate. Works for class instances.'field' in xnarrows to the variant of the union that hasfield.- Truthiness:
if (user)narrows outnull | 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} />;
}
}
- • Use typeof, instanceof, in for built-in narrowing
- • Write type guards for repeated checks
- • Use discriminated unions for state machines
- • 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'
- • Use as const for configuration arrays/objects
- • Derive union types from value arrays
- • Know it makes results readonly
- • 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>>>isT.NonNullable<T>— stripsnull | undefinedfrom 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
- • Reach for Partial, Pick, Omit, Record daily
- • Use ReturnType to derive types from functions
- • Compose utility types instead of hand-writing variants
- • 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't
enum Status { Idle, Loading, Success, Error }
// Do
const STATUSES = ['idle', 'loading', 'success', 'error'] as const;
type Status = (typeof STATUSES)[number];
- • Prefer string literal unions to enums
- • If using enum, use string-valued (not numeric)
- • Avoid const enum with isolatedModules bundlers
- • Migrate existing enums to as const objects without breaking call sites
- • Inspect the JS that
enumactually 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.
- • 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
- • 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— enablesnoImplicitAny,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 (ESNextfor ESM,CommonJSfor legacy Node). WithNodeNextyou get Node's ESM/CJS dual-resolution.moduleResolution—Bundlerfor Vite/Next/esbuild stacks;NodeNextfor raw Node.jsx—react-jsx(the new automatic runtime, no need to import React) for React 17+;preserveif a bundler handles JSX downstream (most setups).paths— alias@/*tosrc/*so you writeimport { Button } from '@/components/Button'instead of'../../../../components/Button'. Pair withbaseUrl: '.'.noEmit: true— when a bundler (Vite, Next) handles the actual JS emit; TS is just a checker.skipLibCheck: true— skip checkingnode_modules.d.tsfiles. 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).
- • strict: true from day one on new projects
- • target ES2022, module ESNext, moduleResolution Bundler
- • paths alias for clean imports
- • 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
- • Use
import typefor type-only imports - • Enable consistent-type-imports lint rule
- • isolatedModules on for Vite/esbuild builds
- • 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
kindis a string literal — TS narrows fully inside eachcase. - Inside
success,s.dataisT; insideerror,s.errorisE. 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.safeParsereturns a tagged result; we crash with a readable error if anything is missing or wrong.- Downstream code reads
env.Xwith 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 —
__branddoesn'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 theEventMapfrom the union. Omit<EventMap[K], 'type'>onemitmakes the type redundant in the payload.- Subscriber callbacks receive the narrowed event variant — TS does the discrimination.
- Adding a new variant to
AppEventflows through toemit/onautomatically.
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;
}
}
- • Parameter properties for terse constructors
- • readonly for immutable state
- • #field for true runtime privacy
- • 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
satisfieskeeps the precise loader types per route while still validating the shape.RouteParams<P>andRouteData<P>derive the parameter and result type for each path.- The single
loadfunction is fully type-safe per route at every call site. - Adding a new route extends every type automatically.
Hands-on exercises
-
Replace any with unknown. Goal: find every
anyin a real codebase and replace it with a properly narrowed type.- Grep for
: anyandas anyacross the repo. - 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)? - Add a lint rule (
@typescript-eslint/no-explicit-any) to prevent regression. - Run
tsc --noEmitand fix any new errors. - You're done when: zero
any(or each remaining one has a// eslint-disablecomment with justification).
- Grep for
-
Discriminated-union refactor. Goal: turn a "boolean-soup" object into a discriminated union.
- Find an object that has
isLoading,isError,error?,data?shapes (it's everywhere). - Rewrite it as a
LoadState<T>union withkind: 'idle' | 'loading' | 'success' | 'error'. - Update every render site to
switchonkind. - Add an
assertNeverdefault case. - You're done when: impossible states (
isLoading: true, data: ...) are no longer expressible.
- Find an object that has
-
Branded IDs. Goal: prevent passing the wrong ID type to a function.
- Define brands for
UserId,OrgId,ProjectId. - Replace all parameter types
id: stringwith the specific branded ID. - Add constructor functions (
UserId(raw)) and use them at API/DB boundaries. - Run the build; fix every error by adding the brand at the source.
- You're done when: swapping a UserId and OrgId at any call site is a compile error.
- Define brands for
-
Derive types from values. Goal: eliminate type drift between runtime constants and their union types.
- Find a hardcoded
type Status = 'a' | 'b' | 'c'and a runtimeconst STATUSES = ['a', 'b', 'c']. - Rewrite as
const STATUSES = [...] as constandtype Status = (typeof STATUSES)[number]. - Add a new status — confirm types and runtime stay in sync without a second edit.
- Repeat for every routes/permissions/roles enum-like object.
- 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.
- Find a hardcoded
-
Strict-plus migration. Goal: enable two additional strict flags on a project.
- Add
noUncheckedIndexedAccess: truetotsconfig.json. Runtsc --noEmit. - Fix all errors (
arr[i]is nowT | undefined; add null checks or usearr.at(i)?.foo). - Add
exactOptionalPropertyTypes: true. Fix the (fewer) errors that come up. - Commit and watch CI catch a previously-silent class of bugs.
- You're done when: CI is green with both flags on and you have a follow-up doc about gotchas.
- Add
-
Build a typed event bus. Goal: write the pattern from Worked Example 4 from scratch.
- Define an
AppEventdiscriminated union with at least four event types. - Write
on,off,emitwith full type inference. - Add a test that proves emitting a wrong payload type is a compile error.
- Bonus: add a
oncemethod and awildcardsubscriber for logging. - You're done when: every
oncallback receives a strongly-typed event andemitrejects malformed payloads.
- Define an
-
Replace annotations with
satisfies. Goal: preserve literal inference on config constants.- Find at least three constants in your codebase annotated with a wide type (
Record<string, X>,X[], aninterface). - Convert each to
} satisfies T(and addas constwhere you want immutability). - Show, in DevTools / IDE hover, that the inferred type is now more precise than before.
- Use one of the now-narrowed types in a downstream lookup that wouldn't have type-checked before.
- You're done when: no widening regret remains and one downstream consumer benefits.
- Find at least three constants in your codebase annotated with a wide type (
-
Author a small utility type library. Goal: practice mapped + conditional types.
- Implement
DeepPartial<T>,DeepReadonly<T>,Nullable<T>,Result<T, E>. - Add types-only unit tests (
type _Test = Expect<Equal<...>>pattern; copy from@type-challenges/utils). - Wire them into a real domain type and use them.
- Add
NoInfer<T>(TS 5.4+) to a function that was inferring too aggressively. - You're done when: the library has four utility types, each with a passing type test.
- Implement
-
Type a third-party JS library. Goal: write your own
.d.tsfor a small untyped npm package.- Pick a small no-types package (or use a deliberately untyped local file).
- Write a
module 'pkg' { ... }declaration in atypes/global.d.tsfile. - Add it to
tsconfig'sinclude. - Verify autocomplete works in a consumer file.
- You're done when: the consumer file is fully typed with no
@ts-ignore.
Self-check questions
- What does "structural typing" mean, and how does it differ from nominal typing?
- When would you use
interfaceovertypealias? - Difference between
unknown,any, andnever— and where does each belong? - Write a discriminated union for the four states of an async fetch.
- Explain
as const. Why does it give you tuple types andreadonlyproperties? - What does the
inferkeyword do inside a conditional type? Give an example. - What does
Partial<Pick<User, 'name' | 'email'>>evaluate to? - Explain the branded-type pattern. Why is it "phantom"?
- What is exhaustiveness checking, and how does
neverenable it? - When does TypeScript widen a literal type, and how do you stop it?
- Why does
noUncheckedIndexedAccesschangearr[0]fromTtoT | undefined? - Difference between
Partial<T>andReadonly<T>andRequired<T>. - What does
Awaited<Promise<Promise<number>>>evaluate to, and why? - When would you reach for a type guard function vs the built-in
in/typeof? - What does
satisfiesgive you that a plain annotation doesn't? - Why is a string-literal union usually preferable to a TypeScript
enum? - What problem does
noUncheckedIndexedAccesssolve, and what costs does it add?
High-signal resources
Official docs
- TypeScript Handbook — read it cover-to-cover, it's short.
- TypeScript Release Notes — every minor version adds something worth knowing.
- tsconfig reference — every flag with rationale.
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
- Matt Pocock — TypeScript Tips — short, focused, hundreds of them.
- Mike North — TypeScript Fundamentals (Frontend Masters paid course, very high signal).
- Anders Hejlsberg talks — design rationale from the architect.
- Dan Vanderkam blog — companion writing to the book.
Weekly milestones
- Day 1 — Read the TypeScript Handbook's "Everyday Types" and "Narrowing" chapters. Do Exercise 1.
- Day 2 — Read "Type Manipulation" (Generics, Keyof, Mapped, Conditional). Do Exercise 4.
- Day 3 — Read tsconfig reference for
strictandmoduleflags. Do Exercise 5. - Day 4–5 — Do Exercises 2 and 3. Answer self-check 1-7.
- 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