Frontend / Typescript / 10_typescript_pitfalls.md

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

Updated 6 interview angles 8 min read source
On this page15
  • any vs unknown vs never — when each?
  • type vs interface — what actually differs?
  • What’s an excess property check, and when does it fire?
  • When does a type assertion (as) actually lie?
  • What does as const do?
  • What does void mean in TypeScript?
  • What’s a type predicate (x is T) vs an assertion function (asserts x is T)?
  • What’s the deal with Function and Object?
  • What’s the difference between {}, object, and Record?
  • Why does TS think my array is widened to (A | B)[] when I want a tuple?
  • A Promise returned from an async function is Promise again — does await unwrap recursively?
  • More gotchas
  • Related
  • Further reading
  • Interview angle
  • TypeScript Pitfalls — any/unknown/never, type vs interface, and Other Senior Traps

    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.

    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 arrays — readonly T[] and ReadonlyArray<T> are equivalent; both prevent .push etc. but don’t deep-freeze.

    Further reading

    Interview angle 6

    • “any versus unknown?” - any is a hole in the type system: it disables checking and spreads through every expression it touches. unknown is a contract - you may hold the value but must narrow before using it. Prefer unknown at every boundary where data arrives from outside.
    • “What is never good for in day-to-day code?” - exhaustiveness. Assigning the switch’s default case to a never-typed variable means adding a new union member breaks the build at every switch that handles it. The error is the feature.
    • “type or interface?” - type for essentially everything; interface when you specifically want declaration merging, which in practice means augmenting a library’s types. Reaching for interface by habit costs you unions, mapped types and conditionals.
    • “When does as actually lie?” - whenever the value never had that shape: casting a parsed JSON response, widening a partial object, or silencing an error you did not understand. Treat every as as a comment claiming “I know more than the compiler”, and if you cannot justify the claim, refactor.
      • Follow-up: “What should you use instead?” - satisfies when you want the value checked but the narrow type kept, a type predicate x is T when you can actually test the shape at runtime, and a schema validator when the data crosses a network boundary.
    • “What does as const buy you?” - literal types instead of widened ones, so an array becomes a readonly tuple and a string stays that exact string. It is what makes route maps, config objects and satisfies patterns work, and it is the cheapest useful feature in the language.
    • “Why is your array (A | B)[] when you wanted a tuple?” - TypeScript widens array literals by default. as const or an explicit tuple annotation is what preserves both the length and the per-position types.