Skip to content

3.7.2 — The Type Vocabulary

3.7.1 set up the machine; this page teaches it to speak. Every TypeScript feature downstream — narrowing, generics, type-level programming — composes the vocabulary built here, so we take each construct properly: syntax, what the checker does with it, where it's used in real code, and the corners that bite. By the end, string | { kind: "err"; code: number }[] reads like a sentence.

1. Primitives, inference, and literal types

The primitives mirror JavaScript's (3.6.7): string, number, boolean, bigint, symbol, null, undefined. But you'll write them less than you expect, because of type inference:

typescript
let count = 42;            // inferred: number       — annotation would be noise
const name = "Ada";        // inferred: "Ada"        — note: the LITERAL type!
let title = "Dr";          // inferred: string       — let may be reassigned, so widened

Two lessons hide in those three lines. First, the working style: annotate function signatures and public boundaries; let inference handle locals — inference is exact, tireless, and never drifts from the code. Second, meet the literal type: "Ada" is a type with exactly one value. const infers the literal; let widens to string (it could be reassigned). Literal types look trivial until you union them:

typescript
type Status = "pending" | "active" | "closed";   // a checked, zero-cost enumeration

function advance(s: Status) { /* … */ }
advance("actve");        // ❌ Argument of type '"actve"' is not assignable

A union of literals gives you exhaustive, typo-proof enumerations with no runtime construct at all — one of TypeScript's best features, and (as section 5 argues) usually a better answer than enum. Autocomplete reads the union; 3.7.3's exhaustiveness checking polices every switch over it.

2. Arrays and tuples

typescript
const xs: number[] = [1, 2, 3];              // array: any length, one element type
const pair: [string, number] = ["Ada", 36];  // tuple: FIXED length, per-slot types

An array is homogeneous and unbounded; a tuple assigns a type to each position — the shape of "several values traveling together": React's useState returns [T, setter], Object.entries yields [string, V] pairs. The full tuple toolkit:

typescript
type Point3 = [x: number, y: number, z: number];   // named slots — pure documentation
type Range  = [start: number, end?: number];        // optional slot
type Row    = readonly [id: number, ...cells: string[]];  // readonly + rest ("variadic")

Named elements label positions in tooltips (nothing at runtime); rest elements type "a fixed head then more" — how 3.7.4 types ...args. And readonly matters doubly here: readonly T[] / ReadonlyArray<T> forbids push/splice/index-assignment — the honest type for function parameters you promise not to mutate. Mutable arrays are assignable to readonly, never the reverse.

Indexing honesty deserves its warning: xs[10] types as number even though it's undefined at runtime — unless 3.7.1's noUncheckedIndexedAccess is on, which makes it number | undefined and forces the check. Turn it on.

3. Object types, and the modifiers that shape them

typescript
type User = {
  id: string;                    // required
  name: string;
  nickname?: string;             // (1) optional — may be absent
  readonly createdAt: Date;      // (2) readonly — assignment is a compile error
};

(1) ? means the property may be missing, and its type becomes string | undefined on read — under strictNullChecks you must handle that before use. (2) readonly forbids reassignment at compile time — shallow, erased, purely intent-enforcement (the runtime counterpart is Object.freeze, 3.6.4).

When keys aren't known in advance, an index signature types the pattern instead:

typescript
type Scores = { [player: string]: number };       // any string key → number
type Flags  = Record<string, boolean>;            // same idea, utility-type spelling

One structural rule explains a whole family of "why won't it compile": excess property checking. Object literals assigned directly to a typed slot are checked for unknown keys ({ name: "Ada", nmae: "oops" } errors — it's a typo detector); the same object passed via a variable is allowed (structural typing's normal "extra properties are fine" — 3.7.7). Literal = strict, indirect = structural.

4. interface vs type — the honest answer

Both declare object shapes, and nine times out of ten they're interchangeable:

typescript
interface Point { x: number; y: number }
type     Point2 = { x: number; y: number };   // same checking, same erasure

The real differences: type can name anything — unions, tuples, primitives, conditional types (type ID = string | number has no interface equivalent) — while interface is objects/callables only. interface supports declaration merging — two declarations of the same name merge their members, which is how library consumers augment Express.Request or Window (3.7.6) — type forbids redeclaration. Interfaces extends (and classes implements) with slightly better error messages and (historically) friendlier caching; types compose with &. A sane team rule: interface for public object contracts (extendable, mergeable), type for everything else — unions, function types, compositions. Arguing beyond that is bikeshedding.

Intersections compose shapes: type Employee = Person & { salary: number } requires all members of both. Caveat: intersecting incompatible members quietly produces never fields — intersections are set-intersection on values, not a merge operation.

5. Enums — honestly assessed

enum predates literal unions and is TypeScript's most debated feature — notably, one of the few that emits runtime code (3.7.1):

typescript
enum Direction { Up, Down }          // numeric: Up = 0, Down = 1
enum Level { Info = "INFO", Warn = "WARN" }   // string enum — safer

Direction[0];        // → "Up" — numeric enums emit a REVERSE-mapped object

Numeric enums have real flaws: any number was historically assignable to them, the reverse mapping bloats output, and values are opaque in logs. String enums fix opacity but remain nominal-ish islands (you must import the enum to use it) and still emit objects. const enum inlines values and emits nothing — but breaks under isolated-module transpilers (esbuild/swc, 3.7.1), so bundler-based repos ban it. Modern default: a union of string literals (type Level = "info" | "warn") — zero runtime, structural, log-readable, exhaustiveness-checkable; add an as const object (3.7.3) when you also need runtime iteration over the values. Reach for enum only when a team convention or API surface genuinely wants the namespaced object.

6. The special types: any, unknown, never, void

Four types form the lattice every value-flow question reduces to. Picture them as a hierarchy: unknown at the top (every type is assignable to it — it promises nothing), never at the bottom (assignable to everything, nothing assignable to it — no value exists), and any as the escape hatch that pretends to be wherever needed.

unknown — the honest topanything fits in; nothing usable until narrowedstring · numberobjects · arraysunions · literalsnever — the empty bottomno value exists; assignable to allanyopts OUT entirely
Figure 1 — The assignability lattice. Arrows read "is assignable to." unknown accepts everything and permits nothing; never is the empty type at the bottom; any (amber) sits outside the lattice — it satisfies every check by disabling checking.

any switches type checking off for that value — every operation allowed, errors silent — and it's contagious: values computed from any are any. It exists for migration and genuine escape hatches; casual use silently deletes the safety you adopted TypeScript for. unknown is the type-safe counterpart: you may hold one, but may not use it until you've proven what it is (3.7.3):

typescript
function handle(a: any, u: unknown) {
  a.toUpperCase();          // compiles — and may explode at runtime
  u.toUpperCase();          // ❌ 'u' is of type 'unknown'
  if (typeof u === "string") u.toUpperCase();   // ✅ narrowed, safe
}

Rule: unknown at every boundary where data arrives from outside (API responses, JSON.parse, catch variables); any only as tracked debt. Difference between never and unknown in TypeScript. [EQ-55]

never is the type with no values — where unknown is "could be anything," never is "nothing can be here." It appears as: the return type of functions that never return normally (throw, infinite loops); the type of a variable after all union members are eliminated — which powers exhaustiveness checking (3.7.3 builds the pattern); and the absorbing element in type-level programming (3.7.5 uses it as "filter this out"). void is milder than never: "returns, but with nothing worth using" — a normal completion whose value you ignore. (Quirk to know: a callback typed () => void accepts value-returning functions — deliberately, so arr.forEach(cb) can take anything.)

7. The expert lens

Model data as unions from day one. The vocabulary's center of gravity is not interface — it's the union. Real domain states are alternatives: loading or loaded or failed; guest or member or admin. Teams that model these as one fat interface of optionals ({ data?: T; error?: E; loading?: boolean }) create illegal states that type-check (loading && error); teams that model them as a union of precise variants make illegal states unrepresentable — the discriminated-union pattern 3.7.3 completes. When a shape sprouts its third optional property, ask whether it's secretly a union.

Inference is the pleasant to use contract. TypeScript's bet — infer locals, annotate boundaries — is what keeps well-typed code looking like JavaScript. Over-annotation is real debt: redundant local annotations go stale and hide inference regressions (an annotation silently coerces where inference would have flagged drift). The reflex: write the implementation, hover to check what inference concluded, annotate only where the conclusion should be narrower or is a public promise.

Runtime-emitting features are the odd ones out. enum (and namespaces, and parameter properties) date from before the erasable-layer philosophy fully won. The ecosystem's direction — literal unions, as const, erasableSyntaxOnly, Node's type stripping — is convergence on "types are only types." When choosing between two designs, prefer the one that vanishes.

Next: the checker's dynamic half — 3.7.3: how control flow refines these types, and the guard/discriminant/assertion toolkit that makes unions practical.

Recall

  • Inference types most code (const infers literal types; let widens). Style: annotate signatures/boundaries, infer locals. Unions of literals = zero-cost checked enumerations — usually better than enum (which emits runtime objects; const enum breaks strippers).
  • Tuples type positions ([string, number], named/optional/rest slots, readonly); arrays are unbounded+homogeneous; noUncheckedIndexedAccess makes indexing honest. ? = may be missing (handle undefined); readonly = compile-time no-reassign; index signatures/Record for open key sets; object literals get excess property checking.
  • interface = objects only, extends, declaration merging (augmentable — the library-contract choice); type = names anything (unions!, tuples, functions), composes with &. Pick a convention, stop arguing.
  • The lattice: unknown top (hold anything, prove before use — the boundary type), never bottom (no values — non-returning functions, exhaustiveness, type-level filtering), void (returns nothing useful), any (checking OFF, contagious — tracked debt only).

Self-test: Why does const s = "hi" infer differently from let s = "hi", and why does it matter for unions? When is a tuple the right type — name two real APIs shaped like one? Give the two genuine interface/type differences. Place any, unknown, never in the lattice and give each one's legitimate use.

Quiz Bank

FoundationalWhat is the difference between any and unknown?

Both say "type not known," with opposite behavior. any turns checking off: every operation compiles, errors pass silently, and it's contagious — anything computed from any is any, so one leak un-types a call path. unknown is the safe counterpart: everything is assignable to it, but you can't call, index, or operate on it until you narrow it (typeof, instanceof, a schema parse — 3.7.3). Practical rule: unknown at every ingress (API responses, JSON.parse, catch variables — useUnknownInCatchVariables makes the last automatic); any only as a deliberate, tracked escape hatch. In lattice terms: unknown is the honest top of the assignability order; any sits outside the order and satisfies every check by disabling it.

FoundationalWhat is never, and how does it differ from void?

never is the type with no values — the empty set at the bottom of the lattice, assignable to everything (vacuously) with nothing assignable to it. It arises as: the return type of functions that never return normally (always throw or loop forever); the type remaining after control flow eliminates every member of a union — the engine of exhaustiveness checking; and the absorbing "filter it out" element in conditional types (3.7.5). void is much milder: the function returns normally but its value is meaningless (undefined) — "nothing useful," not "nothing possible." Quirk: () => void callback positions accept value-returning functions by design (so forEach can take any function); never positions accept nothing.

Appliedinterface vs type — what are the real differences, and what convention follows?

Interchangeable for plain object shapes (same checking, both erased). Real differences: (1) type can alias anything — unions (string | number), tuples, primitives, function types, conditional/mapped types — while interface only describes object/callable shapes; consequently union-centric domain modeling requires type. (2) interface supports declaration merging — multiple same-name declarations merge, which is the mechanism for augmenting library types (Express.Request, Window3.7.6); redeclaring a type is an error, making it tamper-proof. (3) Minor: extends gives marginally clearer errors than &; intersections silently produce never members when they conflict. Sane convention: interface for public, potentially-extendable object contracts; type for everything else — and don't let the debate consume review time.

InterviewWhy do modern TypeScript teams prefer literal unions over enums?

enum is one of the few constructs that emits runtime code (3.7.1): numeric enums generate a reverse-mapped object (bloat, opaque numbers in logs, historical unsoundness of accepting any number); string enums fix readability but remain importable-object islands; const enum erases but breaks single-file transpilers (esbuild/swc), so bundler-era repos ban it. A union of string literals (type Level = "info" | "warn") gives the same checked enumeration with zero runtime footprint, structural compatibility (any "info" qualifies — no import ceremony), grep-able/log-readable values, autocomplete, and switch exhaustiveness via never. When runtime iteration over the values is also needed, pair the union with a single as const array (const LEVELS = ["info", "warn"] as const; type Level = typeof LEVELS[number]) — one source of truth, still erasable. Enum survives where a namespaced value-object is genuinely wanted as API surface.

StaffA code review shows a state interface { data?: Result; error?: ApiError; isLoading: boolean } and downstream code full of non-null assertions. Critique the modeling and refactor it.

The interface makes illegal states representable: { isLoading: true, data, error } type-checks, as does "neither loading nor data nor error" — so downstream code can't prove which combination holds and resorts to data! non-null assertions, which are unchecked claims that rot into runtime crashes exactly when the impossible state ships. Refactor to a discriminated union, one variant per real state: type State = { status: "idle" } | { status: "loading" } | { status: "success"; data: Result } | { status: "error"; error: ApiError }. Now data exists only when status === "success" — the checker enforces access order, every ! disappears, switch (state.status) narrows each branch (3.7.3), and a never default makes adding a variant break every unhandled site at compile time. Transitions become total functions State → State, testable and typo-proof. The team principle:

model states as alternatives (unions), not as bags of optionals — every optional flag pair is 2^n phantom states, and non-null assertions downstream are the smell announcing it. This is the single highest-leverage TypeScript modeling habit; it costs nothing at runtime (erasure) and removes a class of production bugs.

Flashcards

Flashconst vs let inference

const infers the literal type ("Ada"); let widens (string). Literal unions = zero-cost checked enums.

FlashTuple toolkit

[string, number] per-slot; named slots for docs; optional ? slots; rest ...T[]; readonly for no-mutation. useState/Object.entries shapes.

Flashinterface vs type in one line

interface: objects, extends, declaration merging (augmentable). type: anything (unions/tuples/functions), no redeclare. Convention over debate.

FlashEnum verdict

Emits runtime objects; const enum breaks strippers. Default to literal unions (+ as const array if values needed at runtime).

FlashThe lattice

unknown = top (hold all, prove before use). never = bottom (no values; exhaustiveness). void = returns nothing useful. any = outside, checking off, contagious.

FlashExcess property checking

Object literals assigned directly are checked for unknown keys (typo detector); the same object via a variable passes (structural).

Scenario Drill

DrillYou're designing types for a config-driven feature-flag client: flags have a name, a kind (boolean, percentage, variant), kind-specific payloads, and consumers must get compile errors if they read the wrong payload or forget a kind. Runtime code must also iterate all kinds. Design the types and justify each choice.

The requirements name their own tools. Kinds with kind-specific payloads + wrong-read must not compile = a discriminated union keyed on kind: type Flag = { name: string; kind: "boolean"; enabled: boolean } | { name: string; kind: "percentage"; rollout: number } | { name: string; kind: "variant"; variants: readonly string[]; assignment: Record<string, string> }. Reading flag.rollout without first checking kind === "percentage" is now a compile error, and switch (flag.kind) narrows each branch to its exact payload (3.7.3).

Forgetting a kind must not compile = exhaustiveness: a default branch assigning to never breaks every consumer switch when a fourth kind is added — the change-management guarantee. Runtime iteration of kinds = the as const single-source pattern, since a pure type-level union is erased: const KINDS = ["boolean", "percentage", "variant"] as const; type Kind = typeof KINDS[number] — the array exists at runtime for iteration/validation, the union is derived from it, and they can never drift.

Supporting choices: readonly string[] for variants (client code must not mutate config); Record<string, string> index signature for open user→variant assignment; no enum (runtime bloat, stripper hazards — the as const array already covers the runtime need); interface unnecessary since union members are closed variants — type throughout. Add the ingress honesty: config arrives as JSON, so it lands as unknown and passes a schema parse before ever becoming Flag (3.7.7) — erasure means the beautiful union checks nothing about the wire. The drill's principle: discriminated union for closed alternatives, as const bridge where runtime needs the list, unknown+validation at the door.