Frontend / Typescript / 10_typescript_pitfalls.md

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

Updated 7 min read source
On this page6
  1. TL;DR
  2. In depth
  3. More gotchas
  4. What a senior is expected to say
  5. Cross-references
  6. Further reading

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.

In depth

any vs unknown vs never — when each?

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
ts
// 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
  }
}

type vs interface — what actually differs?

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).

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

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

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

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

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.

When does a type assertion (as) actually lie?

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.

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

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

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.”

What does as const do?

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

ts
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.

What does void mean in TypeScript?

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.
ts
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

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

ts
// 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);
// narrowed for rest of scope
data.id;

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

What’s the deal with Function and Object?

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:

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

// 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

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

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.

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

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

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

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

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 Declaration Merging and Module Augmentation.
  • 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 5

  • “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