03 · TypeScript
A structural, gradual type system that compiles to JavaScript. By 2026 it’s the default for serious frontend work. Types are not decoration — they’re a compile-time proof system that catches a large class of bugs before runtime and acts as live documentation.
Positioning
TypeScript sits between you and 02-javascript-deep-dive.md: it adds a static type layer, erases at compile time, and emits plain JS. Its power is in modeling your domain so precisely that illegal states are unrepresentable. The senior skill is using the type system to encode invariants, not just to annotate : string everywhere.
Foundations: what kind of type system this is
- Structural (“duck”) typing — compatibility is by shape, not by name. If an object has the required members, it fits the type, regardless of declared class. This is the opposite of Java/C#‘s nominal typing and it’s why TS feels fluid.
- Gradual —
anyopts out; you can adopt incrementally.unknownis the safe top type (must be narrowed before use);neveris the bottom type (no values; used for exhaustiveness and impossible branches). - Erased — types vanish at compile time; there is no runtime type information. You cannot
instanceofan interface. Runtime validation needs a separate tool (Zod, Valibot, ArkType). - Sound-ish, not sound — TS deliberately allows some unsoundness (e.g., array covariance,
any) for ergonomics. Know where the holes are.
Deep dive
1. The type primitives and how to combine them
- Primitives:
string,number,boolean,bigint,symbol,null,undefined,void,object. - Literal types:
'GET' | 'POST',200 | 404. Combine with unions for finite domains. - Union (
|) = “one of”; Intersection (&) = “all of.” Unions model alternatives (discriminated unions below); intersections compose shapes. interfacevstype: interfaces are open (declaration merging) and idiomatic for object/class contracts;typealiases can express unions, tuples, mapped/conditional types — anything. Preferinterfacefor public object shapes,typefor everything else. Don’t agonize; they overlap heavily.
2. Narrowing and control-flow analysis
TS tracks types through control flow. Type guards narrow:
function format(x: string | number) {
if (typeof x === 'number') return x.toFixed(2); // x: number here
return x.trim(); // x: string here
}
Guards: typeof, instanceof, in, truthiness, equality, and user-defined type guards (function isCat(a: Animal): a is Cat). The discriminated union is the workhorse pattern:
type Result<T> =
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
function render<T>(r: Result<T>) {
switch (r.status) {
case 'loading': return spinner();
case 'success': return view(r.data); // data exists only here
case 'error': return banner(r.error);
default: { const _exhaustive: never = r; return _exhaustive; } // compile error if a case is missed
}
}
The never assignment gives you exhaustiveness checking — add a new variant and the compiler forces you to handle it. This single pattern eliminates a huge class of bugs.
3. Generics
Type parameters make code reusable while preserving information:
function first<T>(arr: readonly T[]): T | undefined { return arr[0]; }
- Constraints:
<T extends { id: string }>restricts whatTcan be. - Defaults:
<T = unknown>. - Inference: TS infers
Tfrom arguments; design APIs so inference flows (the “infer at the call site” goal). - The mental trap: don’t over-genericize. A generic that appears once isn’t a generic — it’s just
anywith ceremony. A type parameter should relate two or more positions (input ↔ output).
4. The type-level toolbox (where TS gets powerful)
keyof T— union of T’s keys. Indexed accessT[K]— the type at a key. Together they let you write key-safe utilities.- Mapped types — transform every property:
type Partial<T> = { [K in keyof T]?: T[K] }; type Readonly<T> = { readonly [K in keyof T]: T[K] }; type Nullable<T> = { [K in keyof T]: T[K] | null }; - Conditional types —
T extends U ? X : Y, withinferto extract:
Conditional types distribute over unions (type ElementType<T> = T extends (infer E)[] ? E : never; type Awaited2<T> = T extends Promise<infer R> ? R : T; type ReturnType2<F> = F extends (...a: any[]) => infer R ? R : never;T extends ... ? ...applied toA | Bruns per-member) — a subtle, powerful, and occasionally surprising behavior. - Template literal types — type-level strings:
type Route = `/${string}`;type EventName<T extends string> = `on${Capitalize<T>}`. Used for route typing, CSS-in-JS, event maps. - Built-in utility types to know cold:
Partial,Required,Readonly,Pick,Omit,Record,Exclude,Extract,NonNullable,ReturnType,Parameters,Awaited,Parameters,InstanceType.
5. Variance, covariance, and readonly
- Function parameters are contravariant, return types covariant (under
strictFunctionTypes). This is why a handler typed(e: Event) => voidaccepts a(e: MouseEvent) => void? — no, the reverse: a more specific param is unsafe; TS catches it for function types (but historically not for method shorthand). Understand bivariance holes around methods. - Arrays are covariant and unsound —
Dog[]is assignable toAnimal[], but you can then push aCat.readonly T[](orReadonlyArray<T>) closes the mutation hole and signals intent. - Prefer
readonlyaggressively for inputs you don’t mutate; it documents and prevents accidental mutation (ties to immutability in02/06).
6. strict mode and config that matters
Turn on "strict": true — it enables strictNullChecks (the single biggest bug-catcher: null/undefined must be handled explicitly), noImplicitAny, strictFunctionTypes, strictBindCallApply, and more. Additional high-value flags:
noUncheckedIndexedAccess—arr[i]becomesT | undefined, forcing you to handle the gap. Pairs with off-by-one safety.exactOptionalPropertyTypes— distinguishes “missing” from “set to undefined.”noImplicitOverride,noFallthroughCasesInSwitch,verbatimModuleSyntax.moduleResolution: "bundler"for modern bundler setups;target/libto control emitted syntax and available globals.
7. Declaration files and the ecosystem boundary
.d.tsfiles describe the types of JS that has none.@types/*(DefinitelyTyped) packages provide types for untyped libraries.- Module augmentation and declaration merging let you extend third-party types (e.g., adding a property to
Window, themingstyled-components’DefaultTheme, extending Express’sRequest). declare globalfor ambient globals;triple-slashdirectives are legacy.
8. The runtime gap: validation at boundaries
Because types are erased, anything crossing a trust boundary (API responses, localStorage, env vars, form input, message payloads) must be validated at runtime. The 2026 standard is a schema library that infers TS types from a single source of truth:
import { z } from 'zod';
const User = z.object({ id: z.string().uuid(), age: z.number().int().nonnegative() });
type User = z.infer<typeof User>; // one schema → type + validator
const user = User.parse(await res.json()); // throws on bad data; `user` is typed AND verified
Alternatives: Valibot (tiny, tree-shakeable), ArkType (fast, TS-syntax). Pattern: parse at the edge, trust the type inside. This is the bridge between TS’s compile-time guarantees and the untyped outside world — and it’s central to BFFs and data enrichment (12).
9. TS with React (the practical core)
- Type props with
interface Props { ... }; prefer explicit return types on exported functions for stable inference. useState<T>()when the initial value doesn’t pin the type;useReducerwith a discriminated-union action type (exhaustiveness in the reducer).useRef<HTMLDivElement>(null); event types from React (React.ChangeEvent<HTMLInputElement>).- Generic components:
function List<T>(props: { items: T[]; render: (t: T) => ReactNode }). - Avoid
React.FC(historic baggage aroundchildrenand generics); type props directly.
Worked example: making illegal states unrepresentable
Bad (six illegal combinations possible):
interface Fetch { loading: boolean; data?: User; error?: Error; }
{ loading: true, data: user, error: err } type-checks but is nonsense. Good:
type Fetch<T> =
| { tag: 'idle' }
| { tag: 'loading' }
| { tag: 'success'; data: T }
| { tag: 'failure'; error: Error };
Now data and error can never coexist; the compiler enforces the state machine. This is the essence of “type-driven design,” and it connects directly to XState and reducer patterns in 06.
Pitfalls & gotchas
- Reaching for
any— it’s a hole that propagates. Preferunknown+ narrowing. - Type assertions (
as) to silence errors — they lie to the compiler; reserve for cases you genuinely know better (and validate at boundaries). - Forgetting types are erased — no runtime checks for free; validate inputs.
- Over-engineering types until they’re unmaintainable. The 80/20: discriminated unions, generics, a few mapped/conditional types. Stop there unless you’re writing a library.
- Enum gotchas (numeric enums leak reverse mappings, aren’t tree-shakeable) — prefer
as constunion objects or string literal unions. - Trusting
JSON.parse(returnsany) — wrap with a validator. - Array covariance and index access returning a wrong non-
undefinedtype withoutnoUncheckedIndexedAccess.
Interview / self-test questions
- Structural vs nominal typing — which is TS, and what does it enable?
unknownvsanyvsnever— when each?- Implement
ReturnType<F>and explaininfer. - What is a discriminated union and how does it give exhaustiveness checking?
- Why must you validate API responses even with TypeScript? (Erasure; types are compile-time only.)
- Explain distribution in conditional types.
- Why is array covariance unsound, and how does
readonlyhelp? - What does
strictNullCheckschange and why is it the highest-value flag? interfacevstype— give a real reason to pick each.- How do you extend a third-party library’s types? (Declaration merging / module augmentation.)
Recommendations
"strict": trueplusnoUncheckedIndexedAccessfrom day one.- Model domains with discriminated unions; add a
neverexhaustiveness guard. - Validate every trust boundary with Zod/Valibot; derive types from schemas, not the reverse.
- Avoid
anyandas; treat each as a code smell requiring justification. - Let inference do the work — annotate inputs and public boundaries, infer internals.
- Keep type gymnastics proportional: app code stays simple; save
infer-heavy wizardry for shared libraries.
Books & references
- “Effective TypeScript” (2nd ed) — Dan Vanderkam. 83 concrete items; the consensus best level-up book. Read items on
any, narrowing, and inference first. - “Programming TypeScript” — Boris Cherny. The systematic ground-up text.
- “Total TypeScript” — Matt Pocock (totaltypescript.com). The de facto modern resource for advanced types, generics, and the “type transformations” workout. His free TS tips are gold.
- The official handbook (typescriptlang.org/docs/handbook) — surprisingly good; the “Type Manipulation” chapter covers mapped/conditional/template-literal types.
- “Type Challenges” (github.com/type-challenges/type-challenges) — graded exercises to build type-level muscle.
- Zod docs (zod.dev) — for the runtime-validation half.
Connections
02-javascript-deep-dive.md— the language TS compiles to and erases into.05-react-internals-and-patterns.md— typing props, hooks, generic components.06-state-management-and-stores.md— typed reducers/stores; XState’s typed state machines mirror discriminated unions.12-bff-and-data-enrichment.md— schema-first contracts and runtime validation at service boundaries.