The AI bug catalog
Every pattern below was written to look exactly like the TypeScript an AI assistant actually produces, compiled with tsc --strict (TypeScript 7.0.2), and — where the bug is a runtime symptom rather than a diagnostic — run with node --experimental-strip-types and quoted verbatim, not reconstructed from memory. Each entry ends with Caught by, naming the one tool that actually flags it, or stating plainly that nothing does.
The 24 patterns are grouped the same way as the Review Checklist that follows this lesson: boundaries & unsafe casts, nullability & falsy checks, async & control flow, generics & variance, erasure & config, and indexing & keys.
Zero type errors, wrong order
async function saveAuditLog(message: string): Promise<void> { await new Promise<void>((resolve) => setTimeout(resolve, 10)); console.log("saved:", message);}
function handleRequest(id: number): string { saveAuditLog(`request ${id} handled`); // missing await -- a floating promise return `response-${id}`;}
console.log(handleRequest(1)); This first pattern is the one worth running yourself before reading any further — the playground above compiles with zero errors under --strict, and prints response-1 before saved: request 1 handled, because the missing await let handleRequest return before the log call ever settled. Everything below follows the same rule: real code, real output, no exceptions.
Boundaries & unsafe casts
Section titled “Boundaries & unsafe casts”1. as on JSON.parse
Section titled “1. as on JSON.parse”as User compiles; the shape was never checked
interface User { id: number; name: string }
const raw = '{"id": 1}'; // "name" is missing from the actual payloadconst user = JSON.parse(raw) as User;console.log(user.name.toUpperCase()); JSON.parse returns any; as User overwrites that with a type the checker never verifies against the actual parsed value.
Symptom — real output:
TypeError: Cannot read properties of undefined (reading 'toUpperCase')Fix: replace the assertion with an actual runtime check — a hand-written type guard, or a schema library:
interface User { id: number; name: string }
function isUser(value: unknown): value is User { if (typeof value !== "object" || value === null) return false; const v = value as Record<string, unknown>; return typeof v.id === "number" && typeof v.name === "string";}
const raw = '{"id": 1}';const parsed: unknown = JSON.parse(raw);if (isUser(parsed)) { console.log(parsed.name.toUpperCase());} else { console.log("invalid user shape");}Caught by: nothing — as is an assertion, not a check; no tool in this course’s list inspects whether an asserted type matches the actual runtime shape. See Typing APIs & Boundaries.
2. any leaking from an untyped library
Section titled “2. any leaking from an untyped library”// @runfunction loadConfigFromUntypedLib(): any { // stands in for a real untyped JS dependency with no .d.ts -- TypeScript can't check its shape return { port: "3000" };}
const config = loadConfigFromUntypedLib();const nextPort = config.port + 1; // any -- no error, but config.port is actually a string hereconsole.log(nextPort);console.log(typeof nextPort);Once a value is typed any, every operation on it — property access, arithmetic, further assignment — is unchecked, and that lost precision propagates through every expression that touches it.
Symptom — real output:
30001string"3000" + 1 is string concatenation, not addition — nextPort is the string "30001", not the number 3001, and nothing about the type system noticed.
Fix: give the boundary a real return type, and validate before trusting it:
interface LibConfig { port: number }
function loadConfigFromUntypedLib(): LibConfig { const raw: unknown = { port: "3000" }; const r = raw as Record<string, unknown>; if (typeof r.port !== "number") throw new Error("expected numeric port"); return { port: r.port };}Caught by: typescript-eslint’s typed no-unsafe-member-access rule, directly — config.port is a member access on a value typed any. See Typing APIs & Boundaries.
3. ! on a value from an API
Section titled “3. ! on a value from an API”// @runinterface Session { user?: { name: string } }
function renderGreeting(session: Session): string { return `Hello, ${session.user!.name.toUpperCase()}`;}
console.log(renderGreeting({}));Symptom — real output:
TypeError: Cannot read properties of undefined (reading 'name')Fix: check, don’t assert:
interface Session { user?: { name: string } }
function renderGreeting(session: Session): string { if (!session.user) return "Hello, guest"; return `Hello, ${session.user.name.toUpperCase()}`;}
console.log(renderGreeting({}));Caught by: typescript-eslint’s no-non-null-assertion, directly — but it only flags the presence of !, not whether this particular one is safe. See Nullability, never & Errors.
4. @ts-ignore instead of a real fix
Section titled “4. @ts-ignore instead of a real fix”// @runinterface Point { x: number; y: number }
function distance(a: Point, b: Point): number { // @ts-ignore -- suppresses "Property 'z' does not exist on type 'Point'" return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2 + a.z);}
console.log(distance({ x: 0, y: 0 }, { x: 3, y: 4 }));@ts-ignore silences whatever error was about to appear on the next line — here, a real one: Point has no z. The comment turns a compile-time error into a silent undefined.
Symptom — real output:
NaNa.z is undefined; undefined added to a number is NaN, and Math.sqrt(NaN) is NaN.
Fix: delete the suppression and the bad field access:
interface Point { x: number; y: number }
function distance(a: Point, b: Point): number { return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2);}
console.log(distance({ x: 0, y: 0 }, { x: 3, y: 4 }));Caught by: typescript-eslint’s ban-ts-comment, directly, on the suppression comment itself; tsc catches the real bug the instant @ts-ignore is deleted (error TS2339: Property 'z' does not exist on type 'Point'.). See Why TypeScript.
5. Record<string, any> instead of a schema
Section titled “5. Record<string, any> instead of a schema”// @runtype Flags = Record<string, any>;
function isEnabled(flags: Flags, name: string): boolean { return flags[name];}
const flags: Flags = { darkMdoe: true }; // typo -- "darkMdoe" not "darkMode"console.log(isEnabled(flags, "darkMode"));Record<string, any> accepts any key, spelled any way, with any value — the typo in the object literal and the typo-free lookup are both “correct” as far as the type is concerned.
Symptom — real output:
undefinedFix: name the real keys:
interface FeatureFlags { darkMode: boolean; betaBanner: boolean }
function isEnabled(flags: FeatureFlags, name: keyof FeatureFlags): boolean { return flags[name];}
const flags: FeatureFlags = { darkMode: true, betaBanner: false };console.log(isEnabled(flags, "darkMode"));With FeatureFlags, the typoed key (darkMdoe) would itself be a compile error (TS2561/excess property check), and isEnabled(flags, “drakMode”)would fail withTS2345on thekeyof FeatureFlags` parameter.
Caught by: nothing for this specific bug — typescript-eslint’s no-explicit-any flags the any inside the Record itself, but not the typo it silently permits. See Typing APIs & Boundaries.
6. catch (e) { e.message }
Section titled “6. catch (e) { e.message }”// @expect-error TS18046function parseAmount(input: string): number { try { return JSON.parse(input); } catch (e) { console.log(e.message); return 0; }}tsc output — real:
error TS18046: 'e' is of type 'unknown'.Since useUnknownInCatchVariables (part of the strict family), a caught value is typed unknown, not any — accessing .message on it without narrowing is a compile error, not a runtime crash waiting to happen.
Fix: narrow before use:
// @runfunction parseAmount(input: string): number { try { return JSON.parse(input); } catch (e) { console.log(e instanceof Error ? e.message : String(e)); return 0; }}
console.log(parseAmount("{not json"));Real output:
Expected property name or '}' in JSON at position 1 (line 1 column 2)0Caught by: tsc directly — error TS18046, no configuration needed beyond --strict. See Typing APIs & Boundaries.
Nullability & falsy checks
Section titled “Nullability & falsy checks”7. Optional + if (x) treats a valid 0 as missing
Section titled “7. Optional + if (x) treats a valid 0 as missing”// @runinterface Product { stockCount?: number }
function availability(product: Product): string { if (product.stockCount) { return `${product.stockCount} in stock`; } return "unknown availability"; // meant for "field not provided", but 0 hits it too}
console.log(availability({ stockCount: 0 }));stockCount: 0 is a perfectly valid, present value — “zero in stock” — but if (product.stockCount) treats 0 exactly like undefined.
Symptom — real output:
unknown availabilityThat should say “out of stock,” not “unknown” — the field was provided, and the code cannot tell the difference between “not provided” and “provided as zero.”
Fix: check presence explicitly:
interface Product { stockCount?: number }
function availability(product: Product): string { if (product.stockCount === undefined) return "unknown availability"; return product.stockCount > 0 ? `${product.stockCount} in stock` : "out of stock";}
console.log(availability({ stockCount: 0 }));Caught by: nothing in this course’s default rule set — --strict has no opinion on a truthiness check against a number | undefined, and the opt-in strict-boolean-expressions typed rule that would flag it is not part of the six typed rules this course installs. See Narrowing.
8. || instead of ??
Section titled “8. || instead of ??”0 retries silently becomes 3
interface Settings { retries?: number }
function getRetries(settings: Settings): number { return settings.retries || 3;}
console.log(getRetries({ retries: 0 })); retries: 0 explicitly means “no retries” — but 0 || 3 evaluates to 3, because || falls through on every falsy value, not just null/undefined.
Symptom — real output:
3Fix: ?? only falls through on null/undefined:
interface Settings { retries?: number }
function getRetries(settings: Settings): number { return settings.retries ?? 3;}
console.log(getRetries({ retries: 0 }));Real output:
0Caught by: typescript-eslint’s prefer-nullish-coalescing, directly, on settings.retries || 3. See Nullability, never & Errors.
9. readonly is shallow
Section titled “9. readonly is shallow”Readonly<T> stops at the top level
interface Address { city: string }interface UserProfile { name: string; address: Address }
function freezeProfile(profile: UserProfile): Readonly<UserProfile> { return profile;}
const profile: Readonly<UserProfile> = freezeProfile({ name: "Ada", address: { city: "London" } });profile.address.city = "Paris"; // Readonly<T> is shallow -- nested objects stay mutableconsole.log(profile.address.city); Readonly<UserProfile> only marks UserProfile’s own properties readonly — address itself cannot be reassigned, but address’s properties are exactly as mutable as before.
Symptom — real output:
ParisThe assignment silently succeeds, both at compile time and at runtime — nothing about Readonly<T> was violated, because Readonly<T> never claimed anything about T’s nested objects.
Fix: a recursive DeepReadonly<T> (and Object.freeze at every level, if runtime enforcement matters too):
// @expect-error TS2540interface Address { city: string }interface UserProfile { name: string; address: Address }
type DeepReadonly<T> = { readonly [K in keyof T]: DeepReadonly<T[K]> };
function freezeDeep<T extends object>(obj: T): DeepReadonly<T> { Object.freeze(obj); for (const key of Object.keys(obj) as (keyof T)[]) { const value = obj[key]; if (value && typeof value === "object") freezeDeep(value as object); } return obj as DeepReadonly<T>;}
const profile = freezeDeep<UserProfile>({ name: "Ada", address: { city: "London" } });profile.address.city = "Paris";tsc output — real:
error TS2540: Cannot assign to 'city' because it is a read-only property.Caught by: nothing for plain Readonly<T> — tsc only catches the nested mutation once the code opts into a recursive DeepReadonly<T> type, as shown above. See Typing APIs & Boundaries.
10. A mutable object shared as a module’s default export
Section titled “10. A mutable object shared as a module’s default export”// @runexport const defaultSettings = { retries: 3, timeoutMs: 1000 };
function loadFeatureA(overrides: Partial<typeof defaultSettings> = {}) { Object.assign(defaultSettings, overrides); // mutates the SHARED default object return defaultSettings;}
function loadFeatureB() { return defaultSettings; // sees whatever loadFeatureA last mutated}
loadFeatureA({ retries: 0 });console.log(loadFeatureB());defaultSettings is one object, imported by every caller. Object.assign(defaultSettings, overrides) does not create a per-call copy — it mutates the single shared object every future import will also see.
Symptom — real output:
{ retries: 0, timeoutMs: 1000 }loadFeatureB, which never asked for any overrides, now reports retries: 0 too — feature A’s one-off override leaked into every other consumer of the same module.
Fix: a factory function that returns a fresh object every call:
// @runfunction createDefaultSettings() { return { retries: 3, timeoutMs: 1000 };}
function loadFeatureA(overrides: Partial<ReturnType<typeof createDefaultSettings>> = {}) { return { ...createDefaultSettings(), ...overrides };}
function loadFeatureB() { return createDefaultSettings();}
console.log(loadFeatureA({ retries: 0 }));console.log(loadFeatureB());Real output:
{ retries: 0, timeoutMs: 1000 }{ retries: 3, timeoutMs: 1000 }Caught by: nothing — this is a shared-mutable-module-state bug, invisible to a type checker (the type of defaultSettings does not change) and to every typed lint rule in this course’s list. See Typing APIs & Boundaries.
Async & control flow
Section titled “Async & control flow”11. Forgotten await (floating promise)
Section titled “11. Forgotten await (floating promise)”The lead demo at the top of this page is this pattern — see it there. saveAuditLog(...) without await compiles clean and returns before its own log line runs.
Fix: await it, or make the caller async all the way up:
async function saveAuditLog(message: string): Promise<void> { await new Promise<void>((resolve) => setTimeout(resolve, 10)); console.log("saved:", message);}
async function handleRequest(id: number): Promise<string> { await saveAuditLog(`request ${id} handled`); return `response-${id}`;}
handleRequest(1).then((r) => console.log(r));Caught by: typescript-eslint’s no-floating-promises, directly — a typed rule that specifically flags a Promise-returning expression statement with no await, no .catch, and no void operator. See Performance & Pitfalls.
12. Promise.all on a widened array, not a tuple
Section titled “12. Promise.all on a widened array, not a tuple”// @expect-error TS2339interface User { name: string }
function fetchUser(): Promise<User> { return Promise.resolve({ name: "Ada" }); }function fetchCount(): Promise<number> { return Promise.resolve(42); }
async function loadDashboard() { const tasks = [fetchUser(), fetchCount()]; // inferred Promise<User | number>[], not a tuple const [user, count] = await Promise.all(tasks); console.log(user.name); // User | number -- the positional types were lost}Passed inline, Promise.all([fetchUser(), fetchCount()]) keeps each position’s own type. Built first into const tasks = [...], the array is inferred as Promise<User | number>[] — a homogeneous array type, not a tuple — and Promise.all on it returns (User | number)[], discarding which position held which type.
tsc output — real:
error TS2339: Property 'name' does not exist on type 'number | User'. Property 'name' does not exist on type 'number'.Fix: pass the tuple inline, so its positions are preserved:
interface User { name: string }
function fetchUser(): Promise<User> { return Promise.resolve({ name: "Ada" }); }function fetchCount(): Promise<number> { return Promise.resolve(42); }
async function loadDashboard() { const [user, count] = await Promise.all([fetchUser(), fetchCount()]); console.log(user.name, count);}
loadDashboard();Caught by: tsc directly (TS2339) — but only reactively, once code tries to use a field the widened type no longer has; nothing warns about the precision loss itself when tasks is first built. See Performance & Pitfalls.
13. Exhaustive switch with no never check
Section titled “13. Exhaustive switch with no never check”A forgotten case, no error, wrong answer
type Shape = | { kind: "circle"; radius: number } | { kind: "square"; side: number } | { kind: "triangle"; base: number; height: number };
function describeShape(shape: Shape): string { let label = "unknown shape"; switch (shape.kind) { case "circle": label = "circle"; break; case "square": label = "square"; break; // "triangle" forgotten -- no default, no never check, no error } return label;}
console.log(describeShape({ kind: "triangle", base: 3, height: 4 })); The function always returns something, so there is no “missing return” for tsc to flag — a forgotten case just silently falls through to the default label.
Symptom — real output:
unknown shapeFix: a default branch that assigns the remaining value to a variable typed never — this is what actually turns a future missed case into a compile error:
// @expect-error TS2322type Shape = | { kind: "circle"; radius: number } | { kind: "square"; side: number } | { kind: "triangle"; base: number; height: number };
function describeShape(shape: Shape): string { switch (shape.kind) { case "circle": return "circle"; case "square": return "square"; default: { const exhaustiveCheck: never = shape; return exhaustiveCheck; } }}With the triangle case still missing, the never check itself now fails to compile — error TS2322: Type '{ kind: "triangle"; ...}' is not assignable to type 'never'. — exactly the signal a reviewer wants the moment a union grows.
Caught by: typescript-eslint’s switch-exhaustiveness-check, directly — it flags the missing "triangle" case on the switch itself, with no manual never check required; tsc alone only catches a future missed case once a never check like the one above is added by hand. See Nullability, never & Errors.
14. this lost when a method is passed as a callback
Section titled “14. this lost when a method is passed as a callback”// @runclass Counter { count = 0; increment(): void { this.count++; }}
const counter = new Counter();const handler = counter.increment; // detached from its instance[1, 2, 3].forEach(handler);console.log(counter.count);counter.increment is just a function value once read off the instance — passing it to forEach calls it with this bound to whatever forEach uses internally, not counter.
Symptom — real output:
TypeError: Cannot read properties of undefined (reading 'count')Fix: an arrow-function class field captures this lexically at construction:
// @runclass Counter { count = 0; increment = (): void => { this.count++; };}
const counter = new Counter();const handler = counter.increment;[1, 2, 3].forEach(handler);console.log(counter.count);Real output:
3Caught by: typescript-eslint’s unbound-method, directly — a typed rule that flags counter.increment the moment it is referenced without being called or bound. See this, Polymorphism & Binding.
Generics & variance
Section titled “Generics & variance”15. Array<Dog> passed where Array<Animal> is expected
Section titled “15. Array<Dog> passed where Array<Animal> is expected”Dog[] accepted as Animal[], then corrupted
class Animal { speak(): string { return "..."; } }class Dog extends Animal { bark(): string { return "woof"; } }class Cat extends Animal { speak(): string { return "meow"; } }
function addACat(animals: Animal[]): void { animals.push(new Cat()); // perfectly legal for Animal[]}
const dogs: Dog[] = [new Dog(), new Dog()];addACat(dogs); // compiles -- Dog[] is assignable to Animal[]for (const d of dogs) { console.log(d.bark());} Dog[] is assignable to Animal[] because arrays (and their push method, written in shorthand syntax in lib.d.ts) are checked the same permissive, bivariant way this course’s variance section covers for a hand-written Stack<T> — this is the built-in Array hitting the identical hole.
Symptom — real output:
woofwoofTypeError: d.bark is not a functionaddACat pushed a Cat into what dogs still believes is an all-Dog array — the third element breaks the very next .bark() call.
Fix: if the function only needs to read, accept readonly Animal[] — a covariant type that has no push to exploit:
class Animal { speak(): string { return "..."; } }class Dog extends Animal { bark(): string { return "woof"; } }class Cat extends Animal { speak(): string { return "meow"; } }
function describeAll(animals: readonly Animal[]): void { for (const a of animals) console.log(a.speak());}
const dogs: Dog[] = [new Dog(), new Dog()];describeAll(dogs);for (const d of dogs) { console.log(d.bark());}Caught by: nothing — this is the same deliberate, documented unsoundness (array covariance plus method-shorthand bivariance) this course’s variance section already demonstrates on a hand-written class. See Generic Classes & Interfaces — Variance.
16. A type predicate that returns true unconditionally
Section titled “16. A type predicate that returns true unconditionally”isAdmin always says yes
interface Admin { role: "admin"; permissions: string[] }
function isAdmin(user: unknown): user is Admin { return true; // always claims true -- the shape is never actually checked}
function grantAccess(user: unknown): void { if (isAdmin(user)) { console.log(user.permissions.join(", ")); // tsc trusts the predicate completely }}
grantAccess({ name: "definitely not an admin" }); A user-defined type predicate’s signature (user is Admin) is all the checker looks at — it never inspects whether the body actually proves that claim.
Symptom — real output:
TypeError: Cannot read properties of undefined (reading 'join')Fix: implement a real runtime check that matches the claimed shape:
// @runinterface Admin { role: "admin"; permissions: string[] }
function isAdmin(user: unknown): user is Admin { return typeof user === "object" && user !== null && (user as Record<string, unknown>).role === "admin" && Array.isArray((user as Record<string, unknown>).permissions);}
function grantAccess(user: unknown): void { if (isAdmin(user)) { console.log(user.permissions.join(", ")); } else { console.log("access denied"); }}
grantAccess({ name: "definitely not an admin" });Real output:
access deniedCaught by: nothing — a type predicate is trusted unconditionally by every downstream check; no tool in this course’s list evaluates whether a predicate’s body actually proves what its signature claims. See Narrowing.
17. An interface with one implementer and a generic used once
Section titled “17. An interface with one implementer and a generic used once”interface UserRecord { id: number; name: string }
interface Repository<T> { save(item: T): void;}
class UserRepository implements Repository<UserRecord> { save(item: UserRecord): void { console.log("saving", item); }}
new UserRepository().save({ id: 1, name: "Ada" });Repository<T> has exactly one implementer, ever, and T is used in exactly one position — this generic interface buys no polymorphism and no reuse; it is pure indirection an AI assistant added because “repositories implement interfaces” is a familiar shape, not because this codebase needs one.
Symptom: no crash, no diagnostic — the cost is a reader now has to open two declarations (Repository<T> and UserRepository) to understand what one class does, and a second implementer never actually shows up to justify the abstraction.
Fix: delete the interface; keep the concrete class:
interface UserRecord { id: number; name: string }
class UserRepository { save(item: UserRecord): void { console.log("saving", item); }}
new UserRepository().save({ id: 1, name: "Ada" });Caught by: nothing directly — knip’s unused-exports report can surface Repository as an export referenced from only one place, which is a signal worth investigating, but no tool identifies “this generic parameter provides no actual reuse” on its own. See Generic Functions & Constraints.
18. Omit on a union collapses to {}
Section titled “18. Omit on a union collapses to {}”ShapeWithoutKind accepts anything
type Shape = | { kind: "circle"; radius: number } | { kind: "square"; side: number };
type ShapeWithoutKind = Omit<Shape, "kind">; // Omit does not distribute over a union
const bogus: ShapeWithoutKind = { anything: "whatever", nonsense: 123 };console.log(bogus); Omit<T, K> is Pick<T, Exclude<keyof T, K>>, and keyof on a union is the intersection of each member’s keys — here, only "kind" is common to both branches. Exclude<"kind", "kind"> is never, so Pick<Shape, never> is {}: an empty object type that accepts any object at all.
Symptom — real output:
{ anything: 'whatever', nonsense: 123 }ShapeWithoutKind was clearly meant to mean “a Shape without its kind tag” — { radius: number } | { side: number } — but it silently means “any object,” and tsc accepts a completely unrelated shape without complaint.
Fix: a distributive helper that runs Omit once per union member, matching what the author actually intended:
type Shape = | { kind: "circle"; radius: number } | { kind: "square"; side: number };
type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;
type ShapeWithoutKind = DistributiveOmit<Shape, "kind">;
const s1: ShapeWithoutKind = { radius: 5 };const s2: ShapeWithoutKind = { side: 4 };console.log(s1, s2);With the naked type parameter T in a conditional, the union now distributes; passing the same bogus object from above is rejected: error TS2353: Object literal may only specify known properties, and 'anything' does not exist in type 'ShapeWithoutKind'.
Caught by: nothing — Omit/Pick silently failing to distribute over a union is a well-known trap with no diagnostic; the only way to notice is reading (or probing with an Equal check) what the alias actually resolves to. See The Utility Types.
19. A recursive type instantiated too deep
Section titled “19. A recursive type instantiated too deep”// @expect-error TS2589type BuildTuple<N extends number, R extends unknown[] = []> = R["length"] extends N ? R : BuildTuple<N, [...R, unknown]>;
type Deep = BuildTuple<1200>;tsc output — real:
error TS2589: Type instantiation is excessively deep and possibly infinite.A recursive conditional type like this one has a compiler-enforced depth limit — this course’s recursive-types lesson measures the exact boundary (tail-recursive forms succeed up to 999 levels and fail at 1000; this naive form fails far earlier).
Fix: stay well under the measured limit, or restructure to avoid unbounded recursion on user-controlled input:
type BuildTuple<N extends number, R extends unknown[] = []> = R["length"] extends N ? R : BuildTuple<N, [...R, unknown]>;
type Safe = BuildTuple<200>;declare const t: Safe;console.log(t.length);Caught by: tsc directly (TS2589), with no configuration needed — but only once the depth is actually exceeded; nothing warns as the recursion grows toward the limit. See Recursive Types.
Erasure & config
Section titled “Erasure & config”20. enum in an erasable-syntax codebase
Section titled “20. enum in an erasable-syntax codebase”// @runenum Direction { Up, Down, Left, Right }
function move(direction: Direction): string { return Direction[direction];}
console.log(move(Direction.Up));This compiles under tsc --strict with zero errors — enum is completely ordinary TypeScript. The problem only appears when something other than tsc tries to run the file directly, because a numeric enum needs real emitted code (an object with a reverse mapping), not just erased type annotations.
Symptom — real output (node --experimental-strip-types on the exact file above):
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only modeFix: an as const object plus its derived union type is erasable — no special runtime support needed, because it is just an ordinary object literal:
// @runconst Direction = { Up: "Up", Down: "Down", Left: "Left", Right: "Right" } as const;type Direction = typeof Direction[keyof typeof Direction];
function move(direction: Direction): string { return direction;}
console.log(move(Direction.Up));Real output:
UpCaught by: nothing via tsc --strict or any typed lint rule this course installs — only actually running the file (node --experimental-strip-types, or compiling with --erasableSyntaxOnly) surfaces the failure. See Objects, Arrays & Tuples.
21. A parameter property under strip-types
Section titled “21. A parameter property under strip-types”// @runclass Point2 { constructor(public readonly px: number, public readonly py: number) {}}
const pt = new Point2(3, 4);console.log(pt.px, pt.py);A parameter property (public readonly px: number inside a constructor signature) is TypeScript-only shorthand for declaring the field and assigning it in the constructor body — real code has to be emitted for the assignment, so, like enum, it is not erasable.
Symptom — real output:
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript parameter property is not supported in strip-only modeFix: declare the field and assign it explicitly:
// @runclass Point2 { readonly px: number; readonly py: number; constructor(px: number, py: number) { this.px = px; this.py = py; }}
const pt = new Point2(3, 4);console.log(pt.px, pt.py);Real output:
3 4Caught by: nothing via tsc --strict — the same gap as pattern 20: it compiles clean and only fails when something other than tsc (node --experimental-strip-types, esbuild, swc) tries to run it directly. See Build & Emit.
22. const enum reverse mapping and cross-file boundaries
Section titled “22. const enum reverse mapping and cross-file boundaries”// @expect-error TS2476const enum LogLevel { Info, Warn, Error }
function label(level: LogLevel): string { return LogLevel[level];}tsc output — real:
error TS2476: A const enum member can only be accessed using a string literal.A const enum is inlined at every use site and erased entirely — there is no runtime object left to do a reverse lookup (LogLevel[level]) against, which is exactly what TS2476 reports. The same “no runtime object survives” property is why single-file transpilers (esbuild, swc, and tsc --isolatedModules in the general case) cannot support a const enum imported from another file: inlining its value requires seeing the enum’s own declaration, which a per-file compilation pass does not have.
Fix: an as const object, the same erasable-and-inlinable-safe pattern as pattern 20:
// @runconst LogLevel = { Info: 0, Warn: 1, Error: 2 } as const;type LogLevel = typeof LogLevel[keyof typeof LogLevel];
function label(level: LogLevel): string { return level === LogLevel.Warn ? "WARN" : "OTHER";}
console.log(label(LogLevel.Warn));Real output:
WARNCaught by: tsc directly (TS2476) for the reverse-mapping form shown above; the cross-file/single-file-transpiler failure mode is a build-tool limitation, not something tsc --strict itself reports. See Build & Emit.
23. import instead of import type under verbatimModuleSyntax
Section titled “23. import instead of import type under verbatimModuleSyntax”// @skip-verify two-file demo (./types.ts) plus --verbatimModuleSyntax; real tsc output quoted belowimport { UserRecord } from "./types.js"; // value-style import of a type-only symbol
export function greet(user: UserRecord): string { return `Hello, ${user.name}`;}With verbatimModuleSyntax enabled, every import is emitted exactly as written — an ordinary import { UserRecord } for a name that is only ever used as a type produces a real runtime import of something that may not even exist as a value export.
tsc output — real (compiling this file plus a sibling types.ts exporting interface UserRecord, with --verbatimModuleSyntax):
error TS1484: 'UserRecord' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled.Fix: mark the import type-only:
// @skip-verify two-file demo (./types.ts) plus --verbatimModuleSyntaximport type { UserRecord } from "./types.js";
export function greet(user: UserRecord): string { return `Hello, ${user.name}`;}Caught by: tsc directly (TS1484) once verbatimModuleSyntax is on; typescript-eslint’s consistent-type-imports, directly, catches the same mistake proactively even with the flag off. See tsconfig Deep Dive.
Indexing & keys
Section titled “Indexing & keys”24. Object.keys(obj).forEach(k => obj[k])
Section titled “24. Object.keys(obj).forEach(k => obj[k])”// @expect-error TS7053interface Config { host: string; port: number }
function logAll(config: Config): void { Object.keys(config).forEach((key) => { console.log(key, config[key]); });}Object.keys always returns string[], regardless of the object’s actual type — Config has no index signature accepting an arbitrary string, so indexing it with one is unsound as far as the checker is concerned, and it says so.
tsc output — real:
error TS7053: Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'Config'. No index signature with a parameter of type 'string' was found on type 'Config'.Fix: assert the array of keys back to (keyof Config)[] — safe here because Object.keys only ever returns Config’s own enumerable keys:
interface Config { host: string; port: number }
function logAll(config: Config): void { (Object.keys(config) as (keyof Config)[]).forEach((key) => { console.log(key, config[key]); });}
logAll({ host: "localhost", port: 3000 });Caught by: tsc directly (TS7053, part of noImplicitAny under --strict) — one of the few entries in this catalog tsc rejects outright with no extra tooling. See Typing APIs & Boundaries.
Mental Model
Section titled “Mental Model”24 patterns, sorted by who actually catches them:
tsc --strict, directly -> 6 (TS18046), 12 (TS2339, reactively), 19 (TS2589), 22 (TS2476), 23 (TS1484, only with verbatimModuleSyntax), 24 (TS7053)typescript-eslint (typed) -> 2 (no-unsafe-member-access), 3 (no-non-null-assertion), 4 (ban-ts-comment), 8 (prefer-nullish-coalescing), 11 (no-floating-promises), 13 (switch-exhaustiveness-check), 14 (unbound-method), 23 (consistent-type-imports, proactively)Only by actually running it -> 20, 21 (node --experimental-strip-types / --erasableSyntaxOnly; tsc --strict is silent)knip (indirect signal only) -> 17 (single-reference export -- a hint, not a direct catch)Nothing -> 1, 5, 7, 9 (for plain Readonly<T>), 10, 15, 16, 18 -- a third of the catalog