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 |
// 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.
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.
// 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.
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.
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 voidWhat’s a type predicate (x is T) vs an assertion function (asserts x is T)?
// 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:
// 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 keysWhat’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.
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.
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 5
- “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): Type Narrowing and Type Guards satisfiesvsas: The satisfies Operator- Strict flags that interact with these: tsconfig Strictness Flags — What Each One Prevents
- Declaration merging (why
interfacefor public types): Declaration Merging and Module Augmentation
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/