frontend / typescript / 10_typescript_pitfalls.md

TypeScript Pitfalls — any/unknown/never, type vs interface, and Other Senior Traps

7 min read source

TypeScript Pitfalls — any/unknown/never, type vs interface, and Other Senior Traps

TL;DR

A grab bag of the questions interviewers use to separate “I write TS” from “I understand TS.” any is a hole in the type system; unknown is the safe equivalent; never is the type of the impossible. type and interface are mostly interchangeable, with a few specific divergences. Type assertions (as) silently lie to the compiler; assertion functions (asserts) and type predicates (is) tell the truth. Object literals get excess property checks that variables don’t.

Interview Q&A

Q: any vs unknown vs never — when each?

A:

Type Meaning Use when
any “I opt out of typing” — assignable to/from everything almost never; the type system gives up
unknown “I don’t know yet — narrow before use” external input (JSON.parse, fetch().then(r => r.json())), catch (e)
never “this value cannot exist” exhaustiveness checks, impossible branches, functions that always throw
// any — silent danger
const a: any = "hello";
a.foo.bar();           // compiles, crashes at runtime

// unknown — forced narrowing
const u: unknown = "hello";
u.foo.bar();           // error
if (typeof u === "string") u.toUpperCase();   // ok

// never — exhaustiveness
type Shape = { kind: "circle"; r: number } | { kind: "square"; s: number };
function area(s: Shape): number {
  switch (s.kind) {
    case "circle": return Math.PI * s.r ** 2;
    case "square": return s.s ** 2;
    default: const _exhaustive: never = s; return _exhaustive;   // adding a new kind errors here
  }
}

Q: type vs interface — what actually differs?

A:

interface type
Object shapes yes yes
Unions/intersections/conditionals no yes
Mapped types / keyof tricks no yes
Tuples awkward natural
Declaration merging yes no
Extends syntax extends A, B & (intersection)
implements in classes both both
Recursive references both both

Rules of thumb: interface for public-facing object shapes (because of merging — useful when you want library consumers to be able to augment); type for everything else (unions, mapped, conditional, tuple, function types).

Q: What’s an excess property check, and when does it fire?

A: Object literals — but not variables — get a stricter check that flags extra properties.

type User = { id: number; name: string };

const u: User = { id: 1, name: "Ada", email: "x" };  // error — excess property
const raw = { id: 1, name: "Ada", email: "x" };
const u2: User = raw;                                  // ok — variable, not a literal

Workarounds: assign to a variable first (intentional pattern), or use as (lying), or widen the type (User & Record<string, unknown>). Excess property checks exist because people typo widht for width; they’re a feature, not a bug.

Q: When does a type assertion (as) actually lie?

A: Whenever you use it. The compiler trusts you. The check that does run is “is the assertion plausible” (same family of types) — but as User on a {} is allowed because {} is the universal supertype.

const r = JSON.parse(text) as User;   // lie — could be anything

// safer:
const r: unknown = JSON.parse(text);
if (isUser(r)) { /* now narrowed */ }   // type guard, real check at runtime

Use as only when you genuinely have information TS can’t have (DOM query selectors typed as the base element, narrowing across a discriminated union manually). Treat each as as a comment: “I’m overriding the type checker on purpose.”

Q: What does as const do?

A: Tells TS to infer the narrowest type — string literals stay literal, arrays become readonly tuples, properties become readonly.

const a = [1, 2, 3];               // number[]
const b = [1, 2, 3] as const;      // readonly [1, 2, 3]

const c = { dir: "asc" };          // { dir: string }
const d = { dir: "asc" } as const; // { readonly dir: "asc" }

Used heavily with discriminated unions, route maps, and satisfies — preserves literal information for downstream inference.

Q: What does void mean in TypeScript?

A: Two things, depending on position:

  • Return position: the function doesn’t return a meaningful value. void is assignable from anything — you can write Array.prototype.forEach to accept a callback returning anything because its return type is void.
  • Variable position: void is essentially undefined | uninitialized. Almost never useful — use undefined or omit.
type Cb = () => void;
const cb: Cb = () => 42;          // ok — return value ignored
[1, 2, 3].forEach((n) => n * 2);  // ok — callback returns number but forEach wants void

Q: What’s a type predicate (x is T) vs an assertion function (asserts x is T)?

A:

// type predicate — returns boolean and narrows on true branch
function isUser(x: unknown): x is User {
  return typeof x === "object" && x !== null && "id" in x;
}

// assertion function — throws if untrue; narrows AFTER call
function assertUser(x: unknown): asserts x is User {
  if (!isUser(x)) throw new Error("not a user");
}

const data: unknown = await load();
if (isUser(data)) data.id;   // narrowed in branch
assertUser(data);
data.id;                     // narrowed for rest of scope

Use predicates in conditionals; use assertions when “if this isn’t true, we can’t continue.”

Q: What’s the deal with Function and Object?

A: Both are too broad and discouraged. Function is “any callable” with no signature info — you can’t actually call it usefully. Object is “anything except null/undefined” — including primitives. Use precise alternatives:

// bad
function call(fn: Function) { fn(); }   // signature unknown

// good
function call(fn: () => void) { fn(); }

// bad
function tag(x: Object) {}

// good
function tag(x: object) {}              // lowercase: non-primitive
function tag(x: Record<string, unknown>) {}   // object with string keys

Q: What’s the difference between {}, object, and Record<string, unknown>?

A:

Type Means
{} any non-null, non-undefined value — includes primitives! (42 satisfies {})
object non-primitive — excludes string/number/etc.
Record<string, unknown> an object with string keys, each value unknown (must be narrowed)

Almost always you want Record<string, unknown> or a specific shape. {} as a type is a footgun.

Q: Why does TS think my array is widened to (A | B)[] when I want a tuple?

A: Array literals widen by default. Use as const, a tuple annotation, or a function with a tuple return type.

const pair = [1, "a"];                     // (string | number)[]
const tupled = [1, "a"] as const;          // readonly [1, "a"]
const annotated: [number, string] = [1, "a"];

Q: A Promise<T> returned from an async function is Promise<T> again — does await unwrap recursively?

A: await unwraps one level of Promise; the Awaited<T> utility / type system handles arbitrary nesting at the type level. In practice, Promise<Promise<T>> is rare in real code, but Awaited is what makes async return types correct.

More gotchas

  • Function inference vs object inference — passing a generic function as a callback can lose narrowing; sometimes annotating the callback’s first parameter explicitly fixes it.
  • Module augmentation only works in module files — see 07_declaration_merging_augmentation.md.
  • enum vs union of string literals — string-literal unions are usually better (no runtime cost, better type-narrowing, no reverse-mapping surprises). Use const enum if you must have enum syntax and want zero runtime.
  • TS structural typing — two types with the same shape are compatible. There’s no nominal BrandedId unless you fake it with type UserId = number & { __brand: "UserId" }.
  • Generics inside JSX-using files<T> parses as JSX; use trailing comma <T,> or extends unknown.
  • readonly on arraysreadonly T[] and ReadonlyArray<T> are equivalent; both prevent .push etc. but don’t deep-freeze.

What a senior is expected to say

  • “I prefer unknown over any and narrow explicitly. any is a hole; unknown is a contract.”
  • “I use never for exhaustiveness — adding a new union member breaks the switch loudly. That’s a feature.”
  • type for everything; interface only when I want declaration merging (library augmentation).”
  • “An as cast is a comment: ‘I know more than the compiler.’ If I can’t justify it, I refactor instead.”
  • as const is the cheapest, most useful TS feature — it preserves literal types for unions, route maps, and satisfies patterns.”

Cross-references

Further reading