MS Stack Ch 7 — TypeScript
Structural typing, narrowing, generics, discriminated unions, utility types, tsconfig. TypeScript without the over-engineering.
Published Updated
Lesson preparation & details
Level: intermediate
By the end, you should be able to
- Model states and correlated payloads with unions
- Validate external data instead of asserting generic types
- Predict satisfies/as-const inference and compiler configuration boundaries
Bring with you
Sources checked: . A source check does not change the publication or update date.
Editorial review: · What review means
In this article · 35 sections
Chapter 7 of From Novice to Fluent on the Modern Microsoft Web Stack — a 22-chapter self-study plan.
Why this chapter
TypeScript adds static checking to JavaScript. Most type syntax is erased, but features such as enums and parameter properties emit runtime JavaScript. Static checking can reject inconsistent uses of your model; it cannot validate a network response, make a cast true or guarantee that a program is free of runtime exceptions. This chapter uses TypeScript 5.9.3 for its compiler checks and Zod 4 for runtime parsing.
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 explain the contracts in this chapter, 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.
Read the type system — Decode signatures, conditional types, mapped types in real libraries without panicking.
Make illegal states unrepresentable — Use discriminated unions and as const to surface bugs at compile time.
Narrowing fluency — typeof, in, instanceof, custom type guards — pick the right tool.
Generics that earn their keep — Reach for generics only when one type drives another; resist over-generification.
Utility types reflex — Partial, Pick, Omit, Record, ReturnType, Awaited — know them cold.
tsconfig from scratch — Strict, target, module, moduleResolution, jsx, paths — explain every line.
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). An IDE rename can update statically resolved references; removing a field reveals checked consumers that still access it. Dynamic property names, any, JavaScript consumers and external JSON remain outside that guarantee.
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.
When the checker complains, investigate whether it found a program bug, an imprecise model or a deliberate boundary it cannot prove. Do not attach a made-up probability to those possibilities. Prefer a smaller, testable assertion boundary over scattered casts that silence the same underlying problem.
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 (which can encode null but not undefined; missing fields are possible) versus JavaScript property access (which can return 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 models a finite set for checked code; external strings still need 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 fieldsGood 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:
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 extendscan be cheaper for the checker than large intersections: interface relationships can be cached, while intersections require constituent checks. This is a design guideline, not a measured speedup for your project.
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); for the unconstrained identity above, const answer = identity(42) retains the literal type 42; storing the result in a mutable let binding can widen it to 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 underlie property transforms such as Partial and Readonly; ReturnType and Awaited instead use conditional types and inference. 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 UnwrapPromise<T> = T extends Promise<infer U> ? UnwrapPromise<U> : T; // teaching subset; built-in Awaited also handles thenables
// Mapped type
type ReadonlyDeep<T> = T extends (...args: never[]) => unknown ? T
: T extends object ? { readonly [K in keyof T]: ReadonlyDeep<T[K]> } : T;
// Object/array teaching model; Date, Map and Set need explicit policies.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.
type Config = { count: number };
function parseConfig(raw: unknown): Config {
if (typeof raw !== 'object' || raw === null || Array.isArray(raw) ||
!('count' in raw) || typeof raw.count !== 'number' || !Number.isFinite(raw.count))
throw new Error('count must be finite');
return { count: raw.count };
}
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 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 hasMessage(x: unknown): x is { message: string } {
return typeof x === 'object' && x !== null && 'message' in x && typeof x.message === 'string';
}
try { /* ... */ }
catch (e) {
if (hasMessage(e)) console.error(e.message); // claims only the field actually checked
}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.
// JSX fragment: Empty, Spinner, List and ErrorView are application components.
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 <ErrorView 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 the literal type 'hello', whereas let x = 'hello' normally widens to 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
• Use satisfies to check shape; add as const when readonly literal inference is intended • 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'>describes a response shape, but does not remove that property at runtime: construct or validate the response object explicitly.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>>; // UserResponseGood 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). Ordinary enums emit runtime objects; emitted shape and tree shaking depend on the toolchain. Numeric enums also create a reverse mapping (you can do Status[0] and get 'idle'). That bidirectionality sounds cute and is almost never useful.
A string literal union has no runtime representation. An as const object or array still exists at runtime if used; its assertion is erased. This is often simpler than an enum for wire-format values. isolatedModules specifically forbids accessing ambient const enums from declarations, not every local const enum. Local const-enum emission/inlining differs across compilers, so do not publish ambient const enums to single-file-transpiler consumers.
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 LegacyStatus { 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 publishing ambient const enums; inspect local enum output for your compiler
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 compatibility without replacing the expression with the annotated target type. Contextual typing still matters: a mutable string property can remain string, not a literal.
type RouteConfig = Record<'home' | 'admin', { path: string; auth?: boolean }>;
// With annotation — has the declared keys, but broad property types
const routes1: RouteConfig = {
home: { path: '/' },
admin: { path: '/admin', auth: true },
};
// routes1.home.path is string, not the literal '/'
// With as const + satisfies — retains literal values AND validates exact keys
const routes2 = {
home: { path: '/' },
admin: { path: '/admin', auth: true },
} as const satisfies RouteConfig;
// routes2.home.path is '/' (literal)
// routes2.admin.auth is true (literal)
// 'hime' as an object key is an error because the allowed key union is finite
// Record<string, ...> would NOT reject a misspelled new keyReach 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 to check config shapes; add as const when literal/readonly inference is intended • Prefer satisfies + as const over plain annotation for constants • Know the rule: satisfies checks compatibility; contextual typing still affects inference
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 the strict family unless individually overridden: in TypeScript 5.9.3 this includesnoImplicitAny,strictNullChecks,strictFunctionTypes,strictBindCallApply,strictPropertyInitialization,strictBuiltinIteratorReturn,noImplicitThis,useUnknownInCatchVariablesandalwaysStrict. New compiler versions may add checks; pin and test upgrades.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'. Paths need matching bundler/runtime resolution; this flag does not rewrite emitted imports.baseUrlis not required just to use paths.noEmit: true— when a bundler (Vite, Next) handles the actual JS emit; TS is just a checker.skipLibCheck: true— skips checking declaration files, including your own.d.tsfiles, not onlynode_modules. It can reduce check time but hide inconsistent declarations; measure the trade-off and check authored declarations separately.
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).
The migration trap
Turning on strict: true in an existing codebase may reveal many errors; the count depends on its existing contracts, not its line count. One migration strategy is to enable noImplicitAny, resolve those diagnostics, then enable other strict flags in reviewed steps. A small or already well-typed project may support enabling the whole family at once.
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. isolatedModules warns about constructs unsafe for single-file transpilation. verbatimModuleSyntax (TS 5.0+) gives the clearer import rule: imports marked type are erased and unmarked imports are retained. Use both deliberately rather than assuming a transpiler does whole-program checking.
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 lineGood 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 ?? 0} UTF-16 code units`;
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 branch is unreachable for a valid typed union but can throw if unvalidated external data violates the contract.
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().min(1).max(65535).default(3000),
DATABASE_URL: z.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 fields:', parsed.error.issues.map(i => i.path.join('.')));
process.exit(1);
}
return parsed.data;
}
// Application entrypoint only: 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 trusts the parsed object. URL syntax validation is not a protocol/host allowlist; add the database-specific checks. Empty PORT coerces to zero and is rejected. Avoid logging raw configuration or credentials.
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;
declare function loadUser(id: UserId): Promise<{ id: UserId }>; // application implementation omitted
const u = UserId('u_123');
const o = OrgId('o_456');
await loadUser(u); // ✅
// @ts-expect-error OrgId is not UserId
await loadUser(o);
// @ts-expect-error raw string is not UserId
await loadUser('raw');- The brand is phantom —
__branddoesn't exist at runtime; it's pure type-level marker. - These constructors centralize casts, not validation; any caller can still assert a brand. Add runtime parsing at input boundaries.
- 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 = new Set<(e: AppEvent) => void>();
on<K extends AppEvent['type']>(type: K, fn: (e: Extract<AppEvent, { type: K }>) => void): () => void {
const handler = (event: AppEvent) => {
if (event.type === type) fn(event as Extract<AppEvent, { type: K }>);
}; // one audited assertion: runtime discriminant equality narrows the variant
this.handlers.add(handler);
return () => { this.handlers.delete(handler); };
}
emit(event: AppEvent): void {
for (const handler of [...this.handlers]) handler(event);
}
}
const bus = new Bus();
const off = bus.on('cart/add', e => console.log(e.sku, e.qty));
bus.emit({ type: 'cart/add', sku: 'X1', qty: 2 });
off();
// @ts-expect-error missing qty
bus.emit({ type: 'cart/add', sku: 'X1' });- A key remapping mapped type (
as E['type']) builds theEventMapfrom the union. - Emitting a single discriminated event keeps type and payload correlated even when the caller has a union-typed variable. Separate generic type/payload arguments can accidentally lose that correlation.
- Subscriber callbacks receive the narrowed event variant — TS does the discrimination.
- Adding a variant extends the contract. Dispatch iterates a snapshot: unsubscribe affects future emits, while a throwing handler stops the current emit. Test these choices explicitly.
Classes, abstract classes, and decorators (awareness)
The event bus just used a class to own a changing collection of handlers, while its mapped types described which payload each handler may receive. That is a useful place to separate the two roles: the class stores runtime state; TypeScript checks the public contract. This short detour explains the class features you will encounter in service wrappers before the next example returns to deriving contracts from a plain configuration object.
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). TypeScript 5.0 introduced support for the modern decorator proposal alongside legacy experimentalDecorators. They have different checking and emit rules. Modern decorators do not support parameter decorators or emitDecoratorMetadata; a framework requiring those legacy facilities cannot be switched just by removing the flag. Check the framework's actual decorator contract before choosing a mode.
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, parse: (raw: unknown) => T): Promise<T>;
}
class FetchClient extends HttpClient {
async get<T>(path: string, parse: (raw: unknown) => T): Promise<T> {
const res = await fetch(new URL(path, this.baseUrl));
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const raw: unknown = await res.json();
return parse(raw);
}
}Good enough to ship
• Parameter properties for terse constructors • readonly for compile-time assignment restrictions • #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
The event bus derived its handler contract from an event union; this router derives its loader contract from a configuration object. No class hierarchy is needed: routes are declared once, and the parameter and result types are derived from the loaders themselves. Follow how a selected path determines the corresponding loader's input and output rather than treating the type aliases as unrelated utility tricks.
type Loader<P> = (params: P) => Promise<unknown>;
const routes = {
'/users': { loader: async (_params: undefined) => 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']>>;
type RouteCall = { [P in RoutePath]: [path: P, params: RouteParams<P>] }[RoutePath];
function load(path: '/users', params: undefined): Promise<RouteData<'/users'>>;
function load(path: '/users/:id', params: { id: string }): Promise<RouteData<'/users/:id'>>;
function load(path: '/orgs/:orgId/users', params: { orgId: string }): Promise<RouteData<'/orgs/:orgId/users'>>;
async function load(...args: RouteCall): Promise<RouteData<RoutePath>> {
switch (args[0]) {
case '/users': return routes['/users'].loader(args[1]);
case '/users/:id': return routes['/users/:id'].loader(args[1]);
case '/orgs/:orgId/users': return routes['/orgs/:orgId/users'].loader(args[1]);
}
}
const u = await load('/users/:id', { id: 'u_1' });
// u is whatever fetchUser returns — fully inferredsatisfieskeeps the precise loader types per route while still validating the shape.RouteParams<P>andRouteData<P>derive the parameter and result type for each path.- Overloads preserve call-site result types; the discriminated tuple implementation preserves path/parameter correlation. The domain fetch functions must still validate responses.
- Adding a route extends derived types; add the matching overload and switch branch as part of the implementation. The configuration uses
anyonly as a function-shape constraint, not as the input or output ofload.
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 parsing constructors at API/DB boundaries: validate each ID format before applying its brand. The cast-only constructors above are not validators.
- Run the type checker; fix each mismatch at its real source rather than casting the wrong ID kind into the required brand.
- 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 and compile-time negative tests reject malformed event literals. If events arrive as JSON, test a separate runtime validator beforeemit.
- 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 readonly literal types, not runtime freezing). - 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
declare 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?
Worked assessment answers
Literal-inference counterexample: const greeting = 'hello' is a literal, but const obj = { path: '/' } has a mutable string property. Adding satisfies { path: string } does not make that property '/'. Use as const satisfies ... when readonly literal values are intended; use a finite key union to detect misspelled route names.
Boundary counterexample: const publicUser: Omit<User, 'password'> = user does not delete password from user. Construct a new object with permitted fields before serialization. Similarly parseConfig({ count: 'x' }) throws while ({ count: 'x' } as unknown as Config) only lies to the checker.
Bus check: subscribe twice, emit one cart event, unsubscribe one handler and emit again. Expect callback counts 2 then 1. A cart event must include its sku and qty; a logout handler cannot assume those fields exist. Extra properties on an already-typed variable are not generally forbidden by structural typing. A union of independent type and payload variables must not be accepted as though their correlation were known.
Self-check key: (1) assignability normally follows shape, with private/protected exceptions; (2) interfaces support declaration merging; (3) unknown needs narrowing, any bypasses checking, never has no values; (4) use the four-variant AsyncState; (5) const assertions prevent literal widening and give readonly properties/tuples without freezing runtime objects; (6) infer binds a type variable in a conditional branch; (7) the patch has optional name/email only; (8) the brand exists in types, not runtime data; (9) a never argument rejects an unhandled union case; (10) mutable literals widen, const assertions constrain them; (11) arbitrary indexed access can miss; (12) optional, readonly and required are distinct shallow transformations; (13) built-in Awaited recursively unwraps to number; (14) a guard centralizes real checks and must not overclaim; (15) satisfies checks compatibility while retaining expression-specific inference; (16) unions avoid enum emission but companion runtime arrays still cost bytes; (17) indexed-access safety adds missing-value handling at each uncertain lookup.
Utility-type exercise: state whether Date/Map/Set/functions are leaves before writing DeepReadonly. A type that recursively maps every object key without such a policy is not a universal immutable-value model. Negative compiler tests prove rejected source programs; malformed-value runtime tests prove the parser boundary.
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, route params are runtime strings or missing values that require parsing; a generic useParams annotation is not runtime validation, 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. Measure check time in CI before choosing skipLibCheck and incremental caching; those flags do not by themselves establish an acceptable build time.
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.
Types help a new reader navigate signatures and call sites. They remain checked assumptions: inspect the parser, tests and implementation before treating a declaration as a guarantee.
Strict checking removes many statically expressible mistakes, not all production null/type errors. Assertions, any, incorrect declarations, unvalidated JSON and JavaScript consumers can violate the model. Keep boundary validation and relevant defensive checks.
Next chapter → Ch 8 — Frontend build tooling Previous chapter → Ch 6 — Modern frontend baseline
Corrected contracts and failure analysis
Treat network data as unknown until a parser validates it. as T, branded casts and satisfies have no runtime validation effect. A generic get<T> that casts response.json lets the caller lie; a sound boundary accepts a parser from unknown to T and invokes it before returning.
An exhaustiveness helper is useful only for values inside the trusted discriminated union. Deserialized JSON can still reach its throwing branch. Brand constructors shown with casts merely centralize an assertion; add format checks there, and retain server-side authorization regardless of the type. JSON.stringify can return undefined or throw on cycles; a generic payload is not necessarily serializable, and string length counts UTF-16 code units rather than encoded bytes. The event bus uses one assertion after comparing the runtime discriminant with the subscribed type; its store itself holds callbacks accepting the whole union. Keep that assertion boundary small and test dispatch and unsubscribe behavior.
Boundary exercise with solution
Why does const row = JSON.parse(input) as {count:number} not prevent { "count":"x" }?
Solution and reasoning
The assertion is erased. Parse to unknown, check object/non-null/count is a finite number, then construct the typed result. Testing a malformed value exercises the actual trust boundary; a compiler-only negative test does not.
Source-backed review notes
- TypeScript: Documentation - Everyday Types — accessed 2026-10-07. Exact supporting passage: “Like a type annotation, type assertions are removed by the compiler and won’t affect the runtime behavior of your code.”
Version-specific reference contracts
Documentation snapshots accessed 7 October 2026; mutable service/browser documentation is timestamped evidence, not an immutable product-version pin. Exact supporting passages and hashes accompany the review ledger.
- TypeScript: Documentation - TypeScript 4.9 — satisfies preserves expression-specific inference while checking compatibility; finite key constraints detect spelling errors.
- TypeScript: Documentation - TypeScript 3.4 — Const assertions do not recursively freeze runtime objects.
- TypeScript: Handbook - Enums — isolatedModules incompatibility applies specifically to ambient const enums.
- TypeScript: TSConfig Option: paths — paths does not implement runtime alias rewriting.
- Defining schemas | Zod — Runtime parsing validates data; TypeScript assertions do not.
Review and execution boundary
The 9 October 2026 follow-up re-read this complete lesson, corrected generic-inference, declaration-checking and exercise contradictions, and refreshed the strict, skipLibCheck and decorator references. Focused local compiler and parser checks are distinct from application integration. No real HTTP/backend, framework project build, deployment or C# runtime was executed.
TypeScript 5.9.3 / Zod 4.4.3 focused compiler and parser fixtures. satisfies requires 4.9+. Application fetch functions and framework fragments remain explicit seams; no project build was run.
Reviewed on 7 October 2026 against the official source snapshots linked below. Whole-current and original source review plus exact focused compiler/runtime fixtures support these contracts, not arbitrary consumers or deployment. Historical setup commands and optional exercises were not executed. No cloud resources, third-party packages or external side effects were created. Old screenshots and unavailable private assets remain in the private recovery archive, not prerequisites for this lesson.
Pause / Recall / Apply
Can you explain it without the page?
Close the example. Reconstruct the core idea, then change one assumption. Mark complete when you’re ready; you can always undo it.
Stored in this browser only. No account, no sync. Clearing browser data removes your record.