Skip to content

Practical Mastery

You now know how the type system works. This module is about using it well — the judgment calls that separate someone who can pass the compiler from someone who ships maintainable TypeScript.

The through-line is one uncomfortable truth: the compiler only knows what you tell it. Types are a model of your program, and a model can be wrong. An any, a bad cast, or a JSON blob from the network can all lie to the checker. Mastery is knowing where the model meets reality and defending that boundary.

LessonWhat you’ll learn
Typing APIs & boundariesunknown vs any, validating external data, branded types
Nullability & neverstrictNullChecks, Result types, exhaustiveness with never
Performance & pitfallsWhy tsc gets slow, common footguns, when to simplify
Migration strategyMoving a JS codebase to TS without a big-bang rewrite
flowchart LR
  world["untyped world
network, JSON, any, casts"] -->|validate at the edge| edge["boundary
(guards, schemas)"]
  edge -->|trusted types| inside["your typed code
(safe to trust)"]
The typed island in an untyped sea

Everything here is a variation on validate at the edge, trust inside. Push the uncertainty to the boundary of your program, turn it into real types there, and let the whole interior enjoy the guarantees. Get the boundary right and the type system pays you back everywhere else.

Object.keys and Object.entries know less than you think

Section titled “Object.keys and Object.entries know less than you think”

Before the module gets into boundaries, nullability, and migration, it’s worth looking at a boundary you cross dozens of times a day without noticing: reading your own object back out with Object.keys and Object.entries. Both are typed more conservatively than most people expect, and the reason is the same structural typing you already know from everywhere else in this course.

type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
interface Point { x: number; y: number }
declare const p: Point;
const keys = Object.keys(p);
const check1: Equal<typeof keys, string[]> = true; // not ("x" | "y")[]
const entries = Object.entries(p);
const check2: Equal<typeof entries, [string, any][]> = true; // not [string, number][] !

Object.keys(p) is string[], not ("x" | "y")[] — and it’s not a missed opportunity, it’s structurally correct. Because TypeScript is structural, a Point-typed binding can hold an object with extra properties at runtime:

interface Point { x: number; y: number }
const wide = { x: 1, y: 2, z: 3 };
const p: Point = wide; // ✅ wide has every Point property, plus more — structural subtyping allows this
for (const key of Object.keys(p)) {
// key: string — at runtime p (really wide) can have a "z" key the Point type never mentioned
}

If Object.keys promised ("x" | "y")[], that promise would be false the moment you passed in wide. string[] is the only type that’s always true.

Object.entries is the sharper surprise: on a plain object type with no index signature, the lib types fall back to [string, any][] — not [string, number][] even when every property really is a number. That any is silent and it propagates, which is exactly the kind of “compiles clean, lies at runtime” gap this course keeps returning to.

Object.entries degrades to any on a plain interface

interface Prices { apple: number; banana: number; currency: string }
function totalPrice(prices: Prices): number {
let total = 0;
for (const [item, price] of Object.entries(prices)) {
total += price; // price is `any` — Object.entries degrades value types on a plain interface
}
return total;
}
console.log(totalPrice({ apple: 3, banana: 2, currency: "USD" }));
Open in TS Playground

Two patterns recover the precision Object.keys can’t give you:

  • keyof typeof on a const object — when you own the object and know it has no hidden extra keys, as const plus keyof typeof gets you the literal union back.
  • A generic helper with an explicit cast — Object.keys(obj) as (keyof T)[] when you can locally justify (usually because you constructed obj yourself) that no extra keys exist.
const Direction = { Up: "UP", Down: "DOWN", Left: "LEFT", Right: "RIGHT" } as const;
type Direction = (typeof Direction)[keyof typeof Direction];
declare const d: Direction; // "UP" | "DOWN" | "LEFT" | "RIGHT"
function keysOf<T extends object>(obj: T): (keyof T)[] {
return Object.keys(obj) as (keyof T)[]; // you are asserting: obj has no extra own keys
}
const dirKeys = keysOf(Direction); // ("Up" | "Down" | "Left" | "Right")[]

Both patterns are you personally vouching for a guarantee the compiler can’t derive on its own — which is the whole theme of this module in miniature.

Record<string, T> vs Map<K, V> for dynamic keys

Section titled “Record<string, T> vs Map<K, V> for dynamic keys”

When the set of keys isn’t known until runtime — a cache, a tally, a lookup table built from user input — you reach for either Record<string, T> (a plain object) or Map<K, V>. They look interchangeable; they are not.

Record<string, T> is a plain JavaScript object, which means every string is a valid key to the type system — including "__proto__", "constructor", and "toString", names that collide with the object’s own prototype chain. The type system has no concept of “dangerous key,” so code that looks completely ordinary can be a real prototype-pollution vulnerability if the keys ever come from untrusted input:

function merge(target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown> {
for (const key in source) {
const value = source[key];
if (typeof value === "object" && value !== null) {
const existing = target[key];
if (typeof existing !== "object" || existing === null) target[key] = {};
merge(target[key] as Record<string, unknown>, value as Record<string, unknown>);
} else {
target[key] = value;
}
}
return target;
}
const payload: Record<string, unknown> = JSON.parse('{"__proto__":{"polluted":"yes"}}');
merge({}, payload);
console.log(({} as Record<string, unknown>).polluted); // every plain object now has this

A type-checked recursive merge with real prototype pollution

function merge(target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown> {
for (const key in source) {
const value = source[key];
if (typeof value === "object" && value !== null) {
const existing = target[key];
if (typeof existing !== "object" || existing === null) target[key] = {};
merge(target[key] as Record<string, unknown>, value as Record<string, unknown>);
} else {
target[key] = value;
}
}
return target;
}
const payload: Record<string, unknown> = JSON.parse('{"__proto__":{"polluted":"yes"}}');
merge({}, payload);
console.log(({} as Record<string, unknown>).polluted); // every plain object now has this
Open in TS Playground

This is --strict clean — zero diagnostics — and it really does leak "yes" onto every {} literal for the rest of the process, because the recursive merge walks into target["__proto__"], which JavaScript resolves through the prototype-accessor to the real, shared Object.prototype. Map<K, V> doesn’t have this bug class at all: a Map’s keys are stored in an internal slot, never as an object property, so map.set("__proto__", x) is just an ordinary entry with no special meaning.

The other gap is noUncheckedIndexedAccess. By default, reading a Record index gives you the value type straight, with no undefined — even for a key you never set:

const counts: Record<string, number> = { apples: 3 };
function double(n: number): number { return n * 2; }
const result = double(counts["bananas"]); // "bananas" was never set — compiles anyway

That compiles clean under this course’s default flags and returns NaN at runtime (verified: node --experimental-strip-types on this file prints NaN). Turning on noUncheckedIndexedAccess closes exactly this gap — the same read becomes number | undefined, so passing it to double is a compile error:

// @skip-verify requires --noUncheckedIndexedAccess, not part of this course's default flags
const counts: Record<string, number> = { apples: 3 };
function double(n: number): number { return n * 2; }
const result = double(counts["bananas"]); // "bananas" was never set

Verified separately: node_modules/typescript/bin/tsc --ignoreConfig --strict --noUncheckedIndexedAccess --target es2025 --lib es2025 counts.ts reports TS2345: Argument of type 'number | undefined' is not assignable to parameter of type 'number'. on that exact line — the same code, the same --strict, one extra flag, and a real bug turns into a compile error instead of NaN. A Map<string, number> gets you the same honesty for free: .get() is always typed V | undefined, flag or no flag, because a Map never pretends every key exists.

Every tool in this section is answering the same question: how much does the compiler actually know about the keys on this value? Object.keys/Object.entries know only that the value is at least the declared type, so they hand back the widest honest answer (string[], [string, any][]). Record<string, T> knows nothing about which strings are safe, so it accepts "__proto__" exactly as happily as "apples". noUncheckedIndexedAccess is the compiler admitting it can’t prove a key was ever set, and refusing to pretend otherwise. None of this is the compiler being unhelpful — it’s the compiler being exactly as certain as the type you gave it actually justifies, which is the module’s whole thesis one level down from where the other lessons apply it.

ApproachBenefitCost
Record<string, T>Plain object: JSON-serializable, destructures and spreads naturally, works with every API that expects an objectAny string type-checks as a key, including "__proto__"/"constructor"; Object.keys/Object.entries lose key/value precision
Map<K, V>Any key type (not just strings), real insertion-order iteration, .get() is honestly V | undefined, immune to prototype-chain collisionsNot JSON-serializable without a manual step, no object-literal syntax, many APIs still expect a plain object
A named interface/type with known keysFull per-key precision, keyof T works, excess-property checks catch typos on literalsOnly works when the key set is fixed at compile time — no dynamic/user-supplied keys
  • Object.keys(obj) should be typed (keyof T)[] and TypeScript is just being sloppy. It isn’t sloppy — string[] is the only type that stays true when obj’s runtime value has extra properties a structurally-compatible type doesn’t mention, which is always possible.
  • Object.entries on a typed object gives you typed values. Only when the object has an index signature. A plain interface/type with fixed properties degrades the value type to any, silently.
  • Record<string, T> and Map<string, T> are interchangeable for a string-keyed lookup. They differ exactly where it matters most: untrusted keys. Record inherits every hazard of being a real JavaScript object (the prototype chain); Map does not.
  • Turning on noUncheckedIndexedAccess is mostly noise for Records you already control. It only ever fires where a key genuinely might not exist — which, for anything built from user input, config, or partial data, is most Record reads in practice.

Before trusting generated code that reaches for a plain object as a lookup table, check for:

  • Object.keys(obj) or Object.entries(obj) results used as if the value type were precise — verify whether obj’s type actually has an index signature, or is a plain interface where the values quietly became any.
  • A Record<string, T> (or a plain object) built from user-controlled keys — request bodies, query params, uploaded config — with no check against __proto__, constructor, or prototype, especially inside a hand-rolled recursive merge/clone/defaults helper.
  • Any place a Map would remove a whole bug class for free (dynamic, possibly-untrusted keys) but a plain object was used instead, purely out of habit.
  • A Record read with no noUncheckedIndexedAccess and no fallback (?? default) for a key that isn’t provably present.

🐛 Spot the bug

interface Prices { apple: number; banana: number; currency: string }
function totalPrice(prices: Prices): number {
let total = 0;
for (const [item, price] of Object.entries(prices)) {
total += price;
}
return total;
}
console.log(totalPrice({ apple: 3, banana: 2, currency: "USD" }));
Show the answer

This compiles with zero errors under --strict and returns "5USD" at runtime from a function whose return type is declared number. Object.entries on Prices (a plain interface, no index signature) types price as any, so total += price type-checks no matter what price actually is. At runtime, total starts as 0, adds 3 and 2 (both numbers, fine), then adds the string "USD" — JavaScript’s + on a number and a string concatenates, producing the string "5USD", which the function then returns despite its number return type. The fix is to iterate only the numeric keys explicitly ((["apple", "banana"] as const).reduce((sum, k) => sum + prices[k], 0)) or keep prices and metadata in separate objects so Object.entries is never applied to a mixed-type shape.

What is the central truth of this module?
What is the recurring strategy for handling the untyped world?
A project compiles with zero errors. What does that guarantee?
What is the type of keys?
interface Point { x: number; y: number }
declare const p: Point;
const keys = Object.keys(p);
Does this compile under --strict, and what does it print?
function merge(target: Record<string, unknown>, source: Record<string, unknown>) { /* recursive merge */ }
const payload: Record<string, unknown> = JSON.parse('{"__proto__":{"polluted":"yes"}}');
merge({}, payload);
console.log(({} as Record<string, unknown>).polluted);
Which of these is true about Record<string, T> vs Map<K, V> for dynamic, possibly-untrusted keys?