TypeScript vs JavaScript — Find the Bug¶
Practice finding and fixing bugs that come from confusing TypeScript with JavaScript. Every bug here stems from a TS↔JS misunderstanding: assuming types exist at runtime,
anydefeating checks, structural-typing surprises, or transpilation gotchas. Your job: identify the bug without the hint, predict expected vs actual behavior, then read the solution and the why.
How to Use¶
- Read the intent and the buggy code.
- Predict expected vs actual before peeking.
- Open the hint only if stuck.
- Read the solution and, most importantly, the why — the conceptual lesson is the point.
Difficulty Levels¶
| Level | Description |
|---|---|
| 🟢 | Easy — a direct, common TS-vs-JS misconception |
| 🟡 | Medium — any, narrowing, or structural surprises |
| 🔴 | Hard — erasure, transpilation, or soundness subtleties |
Bug 1: instanceof on an Interface 🟢¶
Intent: Branch on whether a value is a Dog.
interface Dog { bark(): void; }
function handle(animal: unknown) {
if (animal instanceof Dog) { // ❌
animal.bark();
}
}
Expected vs Actual: You expect a runtime check that the value is a Dog. Actual: compile error — 'Dog' only refers to a type, but is being used as a value here.
💡 Hint
`instanceof` needs a runtime constructor. Does an interface exist at runtime?🐛 Bug Explanation
**Bug:** Interfaces are **erased** — they have no runtime value, so `instanceof` has nothing to test against. **Why:** TypeScript types disappear during compilation. `instanceof` only works with a real class/constructor function.✅ Solution
Use a structural check (or a `class`): **Lesson:** To check a *type* at runtime, you must check its *structure* with real JavaScript, because the type itself is gone.Bug 2: Iterating a Type 🟢¶
Intent: Loop over all color names.
Expected vs Actual: You expect to print three colors. Actual: 'Color' only refers to a type, but is being used as a value here.
💡 Hint
A `type` is compile-time only. What runtime value can you actually iterate?🐛 Bug Explanation
**Bug:** A union type is not a runtime collection — it's erased. There is nothing to iterate. **Why:** Types describe values; they aren't values themselves.✅ Solution
Keep the runtime data and derive the type from it: **Lesson:** When you need both a type and a runtime list, define the **value** first (`as const`) and derive the **type** from it.Bug 3: any Hides a Real Crash 🟡¶
Intent: Read a nested field from a response.
function getCity(response: any): string {
return response.data.address.city; // compiles, crashes at runtime
}
getCity({ data: {} }); // 💥 Cannot read properties of undefined (reading 'city')
Expected vs Actual: You expect the compiler to warn that address might be missing. Actual: no compile error at all; it throws at runtime.
💡 Hint
What does `any` do to the compiler's checks on every property access?🐛 Bug Explanation
**Bug:** `any` disables all type checking on the value, so the unsafe chain `data.address.city` is accepted. **Why:** `any` is an opt-out. Every access on an `any` value is allowed and stays `any`, so the type checker provides no protection.✅ Solution
**Lesson:** Type the value properly (or as `unknown` and narrow). `any` doesn't fix bugs — it hides them until runtime.Bug 4: Asserting Trust Over Validation 🟡¶
Intent: Parse a JSON request body into a typed object.
interface LoginBody { username: string; password: string; }
function login(raw: string) {
const body = JSON.parse(raw) as LoginBody;
authenticate(body.username, body.password); // password may be undefined!
}
declare function authenticate(u: string, p: string): void;
Expected vs Actual: You expect as LoginBody to guarantee the fields exist. Actual: if raw is '{"username":"x"}', body.password is undefined at runtime with no error.
💡 Hint
`as` is erased. Does it run any check on the actual data?🐛 Bug Explanation
**Bug:** `as LoginBody` is a compile-time assertion that performs **no runtime validation**. The real data can be any shape. **Why:** Type assertions are erased like all type syntax. TypeScript trusts you; it does not verify external data.✅ Solution
Validate at the boundary:function login(raw: string) {
const d: unknown = JSON.parse(raw);
if (
typeof d === "object" && d !== null &&
typeof (d as any).username === "string" &&
typeof (d as any).password === "string"
) {
authenticate((d as LoginBody).username, (d as LoginBody).password);
} else {
throw new Error("Invalid login body");
}
}
Bug 5: Structural Typing Lets the Wrong Object In 🟡¶
Intent: Only accept a User, not an arbitrary object.
interface User { id: number; name: string; }
function deleteUser(u: User) { /* dangerous op */ }
const account = { id: 5, name: "admin", role: "owner" };
deleteUser(account); // compiles — is that intended?
Expected vs Actual: You might expect TypeScript to reject account because it isn't "a User." Actual: it compiles, because account structurally satisfies User.
💡 Hint
TypeScript matches by shape, not by name. Does `account` have everything `User` requires?🐛 Bug Explanation
**Bug (conceptual):** Assuming nominal typing. `account` has at least `id` and `name`, so it *is* a `User` structurally — extra `role` is allowed via a variable. **Why:** TypeScript uses structural typing. This is usually desirable, but it surprises developers from nominal languages.✅ Solution
If you truly need nominal distinction, brand the type: **Lesson:** Structural typing is the default. To get nominal behavior, add a brand. Don't assume name-based identity.Bug 6: Excess Property Check Confusion 🟢¶
Intent: Construct a config object inline.
interface Options { timeout: number; }
const opts: Options = { timeout: 1000, retries: 3 }; // ❌
// ~~~~~~~ error
Expected vs Actual: You expect extra retries to be silently ignored (since structural typing allows extras). Actual: error — Object literal may only specify known properties, and 'retries' does not exist in type 'Options'.
💡 Hint
Direct object **literals** get a stricter check than values passed through a variable.🐛 Bug Explanation
**Bug (conceptual):** Excess property checks fire on direct object literals to catch typos — they're stricter than plain structural assignability. **Why:** TypeScript layers an excess-property safety net on top of structural typing specifically for literals.✅ Solution
Either add the property to the type, or assign via a variable if it's intentional: **Lesson:** Literals get excess-property checks; variables get plain structural checks. Know which rule applies.Bug 7: typeof Returns the JS Type, Not the TS Type 🟢¶
Intent: Log the "kind" of a value as its declared interface.
interface Animal { species: string; }
const cat: Animal = { species: "cat" };
if (typeof cat === "Animal") { // ❌ never true
console.log("it's an animal");
}
Expected vs Actual: You expect typeof cat to be "Animal". Actual: it's "object", so the branch never runs.
💡 Hint
`typeof` is a JavaScript operator. What set of strings can it return?🐛 Bug Explanation
**Bug:** `typeof` returns one of JavaScript's runtime types (`"object"`, `"string"`, `"number"`, ...), never a TypeScript interface name, which doesn't exist at runtime. **Why:** The interface is erased; `cat` is a plain object at runtime.✅ Solution
Check a runtime discriminant instead: **Lesson:** `typeof` reports JS runtime types. For your own types, carry a discriminant value.Bug 8: const enum + Single-File Transpiler 🔴¶
Intent: Use a const enum while building with esbuild/Babel (single-file transpilation).
// shared.ts
export const enum Status { Active, Inactive }
// app.ts
import { Status } from "./shared";
console.log(Status.Active); // works with tsc, breaks with esbuild/isolatedModules
Expected vs Actual: You expect Status.Active to inline to 0 everywhere. Actual: under a single-file transpiler (or isolatedModules), the import can't be resolved/inlined, causing a runtime "Status is not defined" or a compile error.
💡 Hint
`const enum` inlining needs whole-program knowledge. What does a per-file transpiler lack?🐛 Bug Explanation
**Bug:** `const enum` is inlined by `tsc` using cross-file information. Single-file transpilers (esbuild, SWC, Babel) compile each file in isolation and can't inline an imported `const enum`. `isolatedModules` flags this. **Why:** Transpilation tooling that lacks whole-program type info can't perform `const enum` substitution — a real TS↔toolchain mismatch.✅ Solution
Use a regular `enum` (emits a runtime object) or a union of literals: **Lesson:** `const enum` assumes `tsc` does emit. With single-file transpilers, avoid it (enable `isolatedModules` to catch it).Bug 9: Trusting the Compiler Over Array Bounds 🔴¶
Intent: Get the first matching item and use it.
const users = [{ name: "Ada" }, { name: "Linus" }];
const found = users[5]; // typed as { name: string }, but undefined at runtime
console.log(found.name.toUpperCase()); // 💥
Expected vs Actual: You expect the compiler to flag a possibly-missing element. Actual: found is typed { name: string } (not | undefined), so it compiles and then crashes.
💡 Hint
Is the default type system *sound* about array indexing? Which flag changes that?🐛 Bug Explanation
**Bug:** By default, index access returns `T`, not `T | undefined` — a deliberate unsoundness for ergonomics. The compiler can't see that index 5 is out of range. **Why:** TypeScript trades soundness for productivity. Array bounds are not tracked unless you opt in.✅ Solution
Enable `noUncheckedIndexedAccess`, then handle the `undefined`: **Lesson:** The default type system is intentionally unsound about indexing. Turn on `noUncheckedIndexedAccess` and narrow.Bug 10: Generic Type Used at Runtime 🔴¶
Intent: Create a default value based on the generic type T.
Expected vs Actual: You expect to branch on what T is. Actual: 'T' only refers to a type, but is being used as a value here.
💡 Hint
Generics are erased. Is `T` available as a value at runtime?🐛 Bug Explanation
**Bug:** Type parameters are erased — `T` has no runtime existence, so you can't compare or inspect it. **Why:** TypeScript generics are compile-time only (no monomorphization, no reified type info), unlike C++ templates or C#/Java reified-ish generics.✅ Solution
Pass a runtime value (a factory or a tag) instead of relying on `T`: **Lesson:** If you need a type's identity at runtime, pass a real value (factory, constructor, or string tag). The generic parameter alone is gone.Bug 11: Assuming tsc Errors Block the Build 🟡¶
Intent: A failing type-check should stop deployment.
Expected vs Actual: You expect tsc to fail and && to short-circuit. Actual: by default tsc prints the error but still emits JS and exits non-zero — but many setups ignore emit and run stale/other output, or expect no .js at all.
💡 Hint
Does `tsc` emit JavaScript even when there are type errors? Which flag couples checking and emit?🐛 Bug Explanation
**Bug:** Type checking and emit are decoupled. `tsc` emits `.js` even with type errors (though it exits with a non-zero code). Relying on "no output = it failed" is wrong. **Why:** This decoupling lets you run partially-typed code during migration. It surprises people expecting a sound compiler that refuses to emit.✅ Solution
Or in CI, run a dedicated check that emits nothing and gate on its exit code: **Lesson:** Errors are diagnostics, not hard stops. Use `noEmitOnError` (or `--noEmit` as a gate) to make a type error block the pipeline.Bug 12: Empty Interface Accepts Almost Anything 🟡¶
Intent: Constrain a parameter to "some object."
interface Anything {}
function process(x: Anything) {
return x; // accepts numbers, strings, booleans... almost everything
}
process(42); // OK?!
process("hi"); // OK?!
Expected vs Actual: You expect Anything to require an object. Actual: an empty interface is satisfied by nearly any non-null value, so primitives slip through.
💡 Hint
What members does an empty interface require? What does structural typing then allow?🐛 Bug Explanation
**Bug:** An empty interface requires *no* members, so structurally everything except `null`/`undefined` satisfies it — equivalent to `{}`, which is almost `any` for assignability. **Why:** Structural typing + zero requirements = nearly universal acceptance.✅ Solution
Use a precise type — `object` for non-primitives, or a real shape, or `unknown` to force narrowing: **Lesson:** `interface Foo {}` is not a meaningful constraint. Specify required members or use `object`/`unknown`.Bug 13: Downleveling Surprise with this in a Callback 🔴¶
Intent: Keep this bound inside a class method's callback when targeting old JS.
class Timer {
seconds = 0;
start() {
// Author uses a plain function, expecting tsc to "fix" this
setInterval(function () {
this.seconds++; // `this` is wrong at runtime
}, 1000);
}
}
Expected vs Actual: You expect this.seconds to refer to the Timer. Actual: in a plain function, this is the timer's caller context (undefined/global), so it throws or silently fails — regardless of target.
💡 Hint
Does TypeScript change JavaScript's `this` binding semantics? What construct preserves lexical `this`?🐛 Bug Explanation
**Bug:** TypeScript does **not** change JS runtime semantics. A plain `function` rebinds `this`; TS won't auto-fix that. (With `noImplicitThis` the compiler at least warns.) **Why:** TS is a checker, not a new runtime. `this` rules are JavaScript's, untouched by types.✅ Solution
Use an arrow function (lexical `this`): **Lesson:** TypeScript adds types, not new runtime behavior. JavaScript's `this`, closures, and coercions all still apply.Summary of Lessons¶
| Bug | Misconception | Correct mental model |
|---|---|---|
| 1, 2, 7, 10 | "Types exist at runtime" | Types are erased; use runtime values/discriminants |
| 3, 4 | "any/as make it safe" | any disables checks; as validates nothing |
| 5, 6, 12 | "Typing is nominal / strict by name" | Structural typing; literals get excess-property checks |
| 8, 11, 13 | "Transpilation/checking changes JS" | Emit ≠ check; tsc doesn't change JS semantics |
| 9 | "The type system is sound" | It's intentionally unsound; opt in with flags |
The one rule behind all of them: TypeScript = JavaScript + a compile-time type checker that disappears at runtime. When a bug confuses you, ask "is this a type (erased) or a value (runtime)?"