gap·map

TypeScript essentials

[ junior depth ]

How type erasure works

Every annotation, interface, and as in a TypeScript file is deleted during compilation. The emitted JavaScript contains zero type information, so nothing is checked at runtime. A parameter typed string receives whatever JS hands it, and from there the runtime type and coercion rules you know from types-coercion take over unchanged.

function greet(name: string) { return "Hi " + name; }
greet(JSON.parse("42")); // compiles if tsc can't see it; runs; returns "Hi 42"

❌ "as string converts the value". x as T changes the compiler's opinion of x, never the value. If the opinion is wrong, a mistyped value flows on wearing the wrong label. Real input (fetch, JSON, forms) needs runtime validation; a type on boundary data is only as true as the check behind it.

How structural typing works

TypeScript is structurally typed: a value fits a type when it has at least the required members, no matter what its type is called or whether anything implements anything.

interface Pet { name: string }
const dog = { name: "Rex", legs: 4 };
const p: Pet = dog; // OK — has name; extra members don't matter

Two separately declared types with the same shape are fully interchangeable, so a TS type reads as a description of a value; the declared name carries no weight. One carve-out exists for typo-catching on inline literals, covered at middle depth.

How narrowing works on union types

string | number means "one of these, and I'm not saying which". Until you narrow, only operations valid for every arm are allowed. Narrowing is ordinary JS checks that the compiler understands:

function fmt(id: string | number | null) {
  if (id === null) return "-";            // equality narrows null away
  if (typeof id === "string") return id.toUpperCase();
  return id.toFixed(2);                    // only number remains
}

The junior-floor guards: typeof x === "...", equality against a specific value, and "key" in obj for telling object shapes apart. Two traps. typeof x === "object" keeps null in the narrowed type (the classic runtime quirk leaks into the static layer), and truthiness checks like if (x) throw away 0 and "" along with null, which is fine for objects and a bug for string | null.

The compiler tracks all this per branch (control flow analysis) and merges types back after the if. You rarely think about it until it saves you.

strictNullChecks makes absence visible

With strictNullChecks on (assume it always is; without it, null is assignable to everything and the type system is fiction), null and undefined are ordinary union members you must handle. An optional property x?: number reads as number | undefined.

const el = document.getElementById("app"); // HTMLElement | null
el.focus();   // error — could be null
el?.focus();  // narrow, or…
el!.focus();  // …promise the compiler. Unchecked. On you.

! performs no check at compile time or runtime; the compiler believes you and emits nothing. Every ! marks a place where your types couldn't express what you knew. One at an odd corner is normal. A cluster means the type model itself is wrong, and the middle notes cover redesigning it so the assertions disappear.

[ middle depth ]

How discriminated unions model state

When a value has states, give each state its own type and join them in a union sharing a literal-typed discriminant, instead of one type with optional fields:

type Fetch<T> =
  | { status: "loading" }
  | { status: "success"; data: T }      // data REQUIRED here
  | { status: "error"; error: Error };

function view(f: Fetch<string>) {
  switch (f.status) {
    case "success": return f.data;          // narrowed — no `?`, no `!`
    case "error":   return f.error.message;
    case "loading": return "…";
    default: { const _x: never = f; return _x; }
  }
}

Checking the discriminant narrows the whole object to one variant. Compare the "bag" version, { status: string; data?: T; error?: string }: status: string discriminates nothing, data stays T | undefined in every branch, and consumers reach for !. The bag also represents illegal states (success with no data) that every reader must mentally rule out. The union makes them unrepresentable.

The default branch is exhaustiveness checking: while every variant is handled, f narrows to never there, and never assigns to never. Add a fourth status and the assignment stops compiling; the compiler finds every switch you forgot.

Type predicates and assertion functions

When narrowing logic doesn't fit inline, name it:

function isFetched<T>(f: Fetch<T>): f is { status: "success"; data: T } {
  return f.status === "success";
}
function assertPresent<T>(x: T | null | undefined): asserts x is T {
  if (x == null) throw new Error("required value missing");
}

x is T narrows in the caller's if; asserts x is T narrows everything after the call (it models "throws or it's true"). ❌ The compiler verifies the caller's usage, not the claim: a predicate whose body checks the wrong thing is a lie the whole codebase now believes. Keep bodies trivially auditable.

When excess property checks fire

interface Config { width?: number; color?: string }
declare function draw(c: Config): void;

draw({ width: 1, colour: "red" });          // error — unknown key
const opts = { width: 1, colour: "red" };
draw(opts);                                  // fine

Structurally, extra properties are always allowed (junior level). The inline error is a separate rule: a fresh object literal in an assignment or argument position gets checked for unknown keys, because a key nobody can ever read from there is almost certainly a typo. Route the object through a variable, widen with an assertion, or add an index signature, and the check doesn't fire. ❌ "TS forbids extra properties" and ❌ "the variable version is unsafe/any" are both wrong. The check is a typo lint layered on top of assignability, and assignability itself still allows the extra keys.

Literal widening and as const

const s = "GET" infers the literal type "GET"; let s = "GET" widens to string (it may be reassigned). Object properties widen too:

const req = { url: "/a", method: "GET" };            // method: string
send(req.url, req.method);   // error: string ≠ "GET" | "POST"
const req2 = { url: "/a", method: "GET" } as const;  // method: "GET", all readonly

Widening is also why a hand-built { status: "success", … } can fail to match a discriminated union: annotate with the union type (or use as const) so the discriminant stays literal.

String-literal unions vs enums comes down to erasure too. Unions erase completely, compare structurally, and narrow with plain ===; enums emit runtime objects and behave nominally. Default to literal unions; reach for an enum only when you genuinely want a runtime value-set to iterate.

unknown vs any at the boundary

Both any and unknown accept every value. The difference is direction: any also assigns to everything and permits every operation, so one any from JSON.parse or a lazy cast silently infects each value derived from it. unknown permits nothing until you narrow, which is exactly right for boundary data:

const raw: unknown = JSON.parse(body);
if (typeof raw === "object" && raw !== null && "id" in raw) { /* earn the type */ }

Discipline: unknown at the edges, narrowing (or a validation library) to enter the typed core, any never as a default. Writing guards once and reusing them across types is parameterized reuse, covered in the ts-generics module.

[ senior depth ]

How variance works for function assignability

Return types compare covariantly (a function returning more is fine where less is expected) and a function taking fewer parameters is assignable where more are offered, which is why arr.map(x => x) may ignore the index. Parameters are the interesting part. Soundness demands contravariance: a handler must accept at least what it will be given:

type Handler = (e: Event) => void;
const h: Handler = (e: MouseEvent) => e.clientX; // unsound: h may get a KeyboardEvent

With strictFunctionTypes on, that assignment errors for function-typed properties. Method declarations stay bivariant on purpose: full contravariance would make Array<Dog> not assignable to Array<Animal> (because push takes the type parameter as input) and break half of practical TS. Interviewers probe for the exact scoping here: parameters are checked contravariantly under strictFunctionTypes, except method syntax, which stays bivariant so common covariant container use keeps working.

Intersections and unions of function types

& on object types merges members (same-key conflicts on primitives collapse to never, unlike interface extends, which errors loudly; prefer extends for object composition). Two sharper cases:

  • Intersection of function types ≈ overload set. ((x: string) => A) & ((x: number) => B) is callable either way.
  • Union of functions ⇒ intersection of parameters. To call ((x: string) => void) | ((x: number) => void) safely, the argument must satisfy both possible signatures, so it must be string & number, i.e. the call is effectively impossible. Contravariance flips union to intersection. This is the standard "why can't I call this array of mixed callbacks" answer.

satisfies vs annotation vs assertion

One decision rule, in order:

  1. Default: no annotation. Inference gives the narrowest true type.
  2. Annotation (: T) when you want widening: declaring intent up front (let id: string | number), or pinning a public API's shape.
  3. satisfies T when you need both: validate against a type without losing the narrow inference.
  4. as only when you know something the compiler cannot. Even then it verifies overlap; an impossible coercion needs as unknown as T, which should read as a flare in review.
const palette = {
  primary: "#602x1e",            // caught: not a valid entry per the record
  accent: [255, 0, 0],
} satisfies Record<string, string | [number, number, number]>;
palette.accent.map(n => n / 255); // still known to be the tuple, not the union

Annotating : Record<…> would catch the typo but erase which key holds which shape; as would catch nothing. ❌ "as is how you type a variable": an as-typed object silently drifts out of date when the target type grows a field.

Exhaustiveness checking at scale

The middle-level never check scales into a policy: one shared helper, used in every switch over every discriminated union:

export function assertNever(x: never): never {
  throw new Error(`Unhandled variant: ${JSON.stringify(x)}`);
}

Now "add a variant" stops being a code-review reminder and becomes a build break at every site that must care, with a runtime throw as the last line of defense for data older than the code. This is the strongest practical argument for modeling with unions: the compiler distributes your change list.

What noUncheckedIndexedAccess fixes

strictNullChecks still lets arrays lie: xs[10] types as number even when it's undefined. With noUncheckedIndexedAccess, every index and dictionary read becomes T | undefined, and each read site needs a check or a redesign (.at(), destructuring with defaults, Map.get semantics you already respect). Cost: real friction in index-heavy code, occasionally a ! where a loop bound genuinely guarantees presence. Whether to enable it is a per-codebase judgment call; the failure it targets, an out-of-bounds read typed as present, exists either way.

Where to be strict and where to loosen

Strictness is a budget. Spend it where types are load-bearing:

  • Boundaries (network, storage, user input): unknown in, runtime validation, then honest types. Erasure means the compiler is blind past JSON.parse; a type without a validator there is a comment.
  • Core: inferred, narrow, union-modeled types; assertions treated as defects to burn down.
  • Legal loosening: an as wrapped in a well-named function with a comment stating the invariant; test files; the seam around an untyped dependency. The pattern is quarantine: one lie, contained and documented, instead of any metastasizing.

beta

The interviewer part is in the works.

The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.

builds on

unlocks

more TypeScript