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.
voidis assignable from anything — you can writeArray.prototype.forEachto accept a callback returning anything because its return type isvoid. - Variable position:
voidis essentiallyundefined | uninitialized. Almost never useful — useundefinedor 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.
enumvs union of string literals — string-literal unions are usually better (no runtime cost, better type-narrowing, no reverse-mapping surprises). Useconst enumif you must have enum syntax and want zero runtime.- TS structural typing — two types with the same shape are compatible. There’s no nominal
BrandedIdunless you fake it withtype UserId = number & { __brand: "UserId" }. - Generics inside JSX-using files —
<T>parses as JSX; use trailing comma<T,>orextends unknown. readonlyon arrays —readonly T[]andReadonlyArray<T>are equivalent; both prevent.pushetc. but don’t deep-freeze.
What a senior is expected to say
- “I prefer
unknownoveranyand narrow explicitly.anyis a hole;unknownis a contract.” - “I use
neverfor exhaustiveness — adding a new union member breaks the switch loudly. That’s a feature.” - “
typefor everything;interfaceonly when I want declaration merging (library augmentation).” - “An
ascast is a comment: ‘I know more than the compiler.’ If I can’t justify it, I refactor instead.” - “
as constis the cheapest, most useful TS feature — it preserves literal types for unions, route maps, andsatisfiespatterns.”
Cross-references
- Narrowing in depth (type guards,
asserts): 05_narrowing_and_guards.md satisfiesvsas: 06_satisfies_operator.md- Strict flags that interact with these: 09_strictness_flags.md
- Declaration merging (why
interfacefor public types): 07_declaration_merging_augmentation.md
Further reading
- TS Handbook — Everyday Types: https://www.typescriptlang.org/docs/handbook/2/everyday-types.html
- TS Handbook — Type Narrowing: https://www.typescriptlang.org/docs/handbook/2/narrowing.html
- “TypeScript Deep Dive” — Basarat: https://basarat.gitbook.io/typescript/