Practical Mastery
Where knowledge becomes judgment
Section titled “Where knowledge becomes judgment”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.
What this module covers
Section titled “What this module covers”| Lesson | What you’ll learn |
|---|---|
| Typing APIs & boundaries | unknown vs any, validating external data, branded types |
| Nullability & never | strictNullChecks, Result types, exhaustiveness with never |
| Performance & pitfalls | Why tsc gets slow, common footguns, when to simplify |
| Migration strategy | Moving a JS codebase to TS without a big-bang rewrite |
The one idea tying it together
Section titled “The one idea tying it together”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)"]
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 thisfor (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" })); Two patterns recover the precision Object.keys can’t give you:
keyof typeofon aconstobject — when you own the object and know it has no hidden extra keys,as constpluskeyof typeofgets 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 constructedobjyourself) 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 thisA 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 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 anywayThat 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 flagsconst counts: Record<string, number> = { apples: 3 };function double(n: number): number { return n * 2; }const result = double(counts["bananas"]); // "bananas" was never setVerified 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.
Mental Model
Section titled “Mental Model”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.
Trade-offs
Section titled “Trade-offs”| Approach | Benefit | Cost |
|---|---|---|
Record<string, T> | Plain object: JSON-serializable, destructures and spreads naturally, works with every API that expects an object | Any 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 collisions | Not JSON-serializable without a manual step, no object-literal syntax, many APIs still expect a plain object |
A named interface/type with known keys | Full per-key precision, keyof T works, excess-property checks catch typos on literals | Only works when the key set is fixed at compile time — no dynamic/user-supplied keys |
Common Misconceptions
Section titled “Common Misconceptions”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 whenobj’s runtime value has extra properties a structurally-compatible type doesn’t mention, which is always possible.Object.entrieson a typed object gives you typed values. Only when the object has an index signature. A plaininterface/typewith fixed properties degrades the value type toany, silently.Record<string, T>andMap<string, T>are interchangeable for a string-keyed lookup. They differ exactly where it matters most: untrusted keys.Recordinherits every hazard of being a real JavaScript object (the prototype chain);Mapdoes not.- Turning on
noUncheckedIndexedAccessis mostly noise forRecords 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 mostRecordreads in practice.
Reading AI-generated code
Section titled “Reading AI-generated code”Before trusting generated code that reaches for a plain object as a lookup table, check for:
Object.keys(obj)orObject.entries(obj)results used as if the value type were precise — verify whetherobj’s type actually has an index signature, or is a plaininterfacewhere the values quietly becameany.- 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, orprototype, especially inside a hand-rolled recursive merge/clone/defaults helper. - Any place a
Mapwould remove a whole bug class for free (dynamic, possibly-untrusted keys) but a plain object was used instead, purely out of habit. - A
Recordread with nonoUncheckedIndexedAccessand 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.