Skip to content
TypeScript

Platform & Language

Listen 0%
Speed

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.
  • Gradualany opts out; you can adopt incrementally. unknown is the safe top type (must be narrowed before use); never is 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 instanceof an 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.
  • interface vs type: interfaces are open (declaration merging) and idiomatic for object/class contracts; type aliases can express unions, tuples, mapped/conditional types — anything. Prefer interface for public object shapes, type for 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 what T can be.
  • Defaults: <T = unknown>.
  • Inference: TS infers T from 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 any with 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 access T[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 typesT extends U ? X : Y, with infer to extract:
    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;
    Conditional types distribute over unions (T extends ... ? ... applied to A | B runs 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) => void accepts 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 unsoundDog[] is assignable to Animal[], but you can then push a Cat. readonly T[] (or ReadonlyArray<T>) closes the mutation hole and signals intent.
  • Prefer readonly aggressively for inputs you don’t mutate; it documents and prevents accidental mutation (ties to immutability in 02/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:

  • noUncheckedIndexedAccessarr[i] becomes T | 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/lib to control emitted syntax and available globals.

7. Declaration files and the ecosystem boundary

  • .d.ts files 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, theming styled-componentsDefaultTheme, extending Express’s Request).
  • declare global for ambient globals; triple-slash directives 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; useReducer with 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 around children and 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. Prefer unknown + 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 const union objects or string literal unions.
  • Trusting JSON.parse (returns any) — wrap with a validator.
  • Array covariance and index access returning a wrong non-undefined type without noUncheckedIndexedAccess.

Interview / self-test questions

  1. Structural vs nominal typing — which is TS, and what does it enable?
  2. unknown vs any vs never — when each?
  3. Implement ReturnType<F> and explain infer.
  4. What is a discriminated union and how does it give exhaustiveness checking?
  5. Why must you validate API responses even with TypeScript? (Erasure; types are compile-time only.)
  6. Explain distribution in conditional types.
  7. Why is array covariance unsound, and how does readonly help?
  8. What does strictNullChecks change and why is it the highest-value flag?
  9. interface vs type — give a real reason to pick each.
  10. How do you extend a third-party library’s types? (Declaration merging / module augmentation.)

Recommendations

  • "strict": true plus noUncheckedIndexedAccess from day one.
  • Model domains with discriminated unions; add a never exhaustiveness guard.
  • Validate every trust boundary with Zod/Valibot; derive types from schemas, not the reverse.
  • Avoid any and as; 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

Frontend Deep-Dive Library · content is the single source of truth.