Phase 8 · Interview prep · Lesson 8.3
InterviewGotchas and live coding
Fifteen classic TypeScript gotchas that interviewers love, then three live-coding exercises (debounce, pick, typed path get) with hints, solutions and how to talk through them.
45 min
Interviewers like gotchas because they separate people who have read about TypeScript from people who have been bitten by it. Each one below is a real behaviour that surprises developers. The goal isn't trivia: for each, know what happens, why it's designed that way, and the safe alternative.
The second half is live coding: three small functions that interviewers regularly ask you to type from scratch.
How to use this
- For each gotcha, read the code and predict before opening the answer.
- For each live-coding exercise, write the signature first, then the body. Use the hints only when stuck for a few minutes.
- Practise the talk-through at the end out loud at least once. Silence is the most common live-coding failure.
Part 1: fifteen gotchas
1. Excess property checks only apply to fresh literals
type Options = { verbose: boolean };
function run(options: Options) {}
const withTypo = { verbose: true, verbsoe: false };
run(withTypo);Quick check
Does run(withTypo) compile?
2. Object.keys returns string[]
const prices = { apple: 1, pear: 2 };
for (const key of Object.keys(prices)) {
// @ts-expect-error -- Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{ apple: number; pear: number; }'.
console.log(prices[key]);
}Why, and what to do instead
Because of structural typing, an object typed { apple: number; pear: number } may have more keys at runtime, so TypeScript won't promise ("apple" | "pear")[]. Options, from safest:
const prices = { apple: 1, pear: 2 };
for (const [key, price] of Object.entries(prices)) {
console.log(key, price); // price: number
}
// When you control the object and know it has no extra keys:
for (const key of Object.keys(prices) as (keyof typeof prices)[]) {
console.log(prices[key]);
}3. Array index access trusts you
const names: string[] = [];
const first = names[0];Quick check
With default strict settings, what is the type of first?
4. {} is not "an empty object"
const a: {} = 0;
const b: {} = "text";
const c: {} = [1, 2];
// @ts-expect-error -- Type 'null' is not assignable to type '{}'.
const d: {} = null;What {} really means
{} means any value that isn't null or undefined, since every such value has at least zero properties. For "any object" use object; for "an object with no keys" use Record<string, never>; for "anything at all" use unknown.
5. Type assertions lie
type User = { name: string; email: string };
const user = { name: "Ada" } as User; // compiles
console.log(user.email.toLowerCase()); // crashes at runtimeWhat should you use?
as tells the compiler to trust you; it checks nothing at runtime. as is allowed here because { name: string } and User are comparable. Prefer an annotation (const user: User = ..., which reports the missing email) or satisfies, and validate data from outside with a real runtime check:
type User = { name: string; email: string };
// @ts-expect-error -- Property 'email' is missing in type '{ name: string; }' but required in type 'User'.
const user: User = { name: "Ada" };6. Optional is not the same as | undefined
type A = { note?: string };
type B = { note: string | undefined };
const a: A = {};
// @ts-expect-error -- Property 'note' is missing in type '{}' but required in type 'B'.
const b: B = {};Quick check
What does exactOptionalPropertyTypes change about A?
7. readonly is shallow
type Settings = {
readonly theme: string;
readonly tags: string[];
};
function update(settings: Settings) {
// @ts-expect-error -- Cannot assign to 'theme' because it is a read-only property.
settings.theme = "dark";
settings.tags.push("new");
}Quick check
Why does settings.tags.push(...) compile?
8. JSON.parse returns any
const data = JSON.parse('{"count": 1}');
const doubled = data.cuont * 2; // typo, still compiles: data is anyThe safe version
Annotate the result as unknown so the compiler forces you to check it:
function readCount(json: string): number {
const data: unknown = JSON.parse(json);
if (typeof data === "object" && data !== null && "count" in data && typeof data.count === "number") {
return data.count;
}
throw new Error("Invalid payload");
}The same goes for response.json(), which returns Promise<any>.
9. Narrowing is lost inside callbacks
type User = { name: string | null };
function shout(user: User) {
if (user.name !== null) {
[1, 2].forEach(() => {
// @ts-expect-error -- 'user.name' is possibly 'null'.
console.log(user.name.toUpperCase());
});
}
}Why, and the fix
The callback might run later, after something else set user.name back to null, so TypeScript drops narrowings of properties (and of let variables that are reassigned) inside functions. Since TS 5.4, narrowing of parameters and let variables that are never reassigned after that point is kept. The fix is to copy into a const:
type User = { name: string | null };
function shout(user: User) {
const name = user.name;
if (name !== null) {
[1, 2].forEach(() => console.log(name.toUpperCase()));
}
}10. as const is deep
const config = {
env: "prod",
retries: { max: 3, codes: [500, 503] },
} as const;Quick check
What is the type of config.retries.codes?
11. Method syntax is bivariant
type WithMethod = { handle(value: string | number): void };
type WithProperty = { handle: (value: string | number) => void };
const onlyStrings = (value: string) => console.log(value.toUpperCase());
const a: WithMethod = { handle: onlyStrings };
// @ts-expect-error -- Type 'string | number' is not assignable to type 'string'.
const b: WithProperty = { handle: onlyStrings };
a.handle(42); // compiles, crashes at runtimeQuick check
Why is a accepted but b rejected?
12. Conflicting intersections become never
type Circle = { kind: "circle"; radius: number };
type Square = { kind: "square"; size: number };
type Both = Circle & Square;
type Mixed = { id: string } & { id: number };
type MixedId = Mixed["id"];Quick check
What are Both and MixedId?
13. Numeric enums accept any number
enum Status { Active, Disabled }
const n: number = 42;
const fromVariable: Status = n; // compiles!
// @ts-expect-error -- Type '42' is not assignable to type 'Status'.
const fromLiteral: Status = 42;
enum Color { Red = "RED" }
// @ts-expect-error -- Type '"RED"' is not assignable to type 'Color'.
const red: Color = "RED";The rule
Any number value is assignable to a numeric enum (bit flags need that). Since TS 5.0 an out-of-range literal is caught, but a number variable isn't. String enums go the other way: they are nominal, so even the matching string literal is rejected. Many teams avoid both surprises with an as const object and a union type.
14. typeof null is "object"
function describe(value: unknown) {
if (typeof value === "object") {
const narrowed = value;
return narrowed;
}
return null;
}Quick check
What is the type of narrowed?
15. any is contagious
const payload: any = { count: 1 };
const count = payload.count;
const total = count + 1;
const label = `Total: ${total}`;Quick check
What are the types of count, total and label?
Part 2: live coding
Exercise 1: a typed debounce
Write debounce(fn, ms). It returns a function that takes exactly the same arguments as fn and only calls fn after ms milliseconds without another call.
function debounce(fn, ms) {
// your code
}
const save = debounce((id: number, title: string) => {}, 300);
save(1, "draft"); // should compile
save("1", "draft"); // should be an errorHints
- Capture the argument list as a tuple type parameter:
Args extends unknown[]. (...args: Args) => voidkeeps parameter names and optional parameters.- The timer's type differs between the browser and Node:
ReturnType<typeof setTimeout>works in both.
Show the solution
function debounce<Args extends unknown[]>(
fn: (...args: Args) => void,
ms: number,
): (...args: Args) => void {
let timer: ReturnType<typeof setTimeout> | undefined;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}
const save = debounce((id: number, title: string) => console.log(id, title), 300);
save(1, "draft");
// @ts-expect-error -- Argument of type 'string' is not assignable to parameter of type 'number'.
save("1", "draft");▶ Try it in the TypeScript Playground
Worth saying out loud: the returned function is void on purpose, because a debounced call can't return fn's result synchronously. If they ask for the result, the follow-up is to return a Promise<ReturnType>.
Exercise 2: a typed pick
Write pick(obj, keys): return a new object with only those keys. Unknown keys must be errors, and the result type must contain only the picked keys.
function pick(obj, keys) {
// your code
}
const user = { id: 1, name: "Ada", email: "ada@example.com" };
const preview = pick(user, ["id", "name"]); // { id: number; name: string }
pick(user, ["password"]); // should be an errorHints
- Two type parameters:
Tfor the object andK extends keyof Tfor the keys. - The return type is the built-in
Pick<T, K>. - Start the result as an empty object; one assertion inside the implementation is fine, as long as the signature is honest.
Show the solution
type Expect<T extends true> = T;
type Equal<X, Y> =
(<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
function pick<T extends object, K extends keyof T>(obj: T, keys: readonly K[]): Pick<T, K> {
const result = {} as Pick<T, K>;
for (const key of keys) {
result[key] = obj[key];
}
return result;
}
const user = { id: 1, name: "Ada", email: "ada@example.com" };
const preview = pick(user, ["id", "name"]);
type test = Expect<Equal<typeof preview, Pick<typeof user, "id" | "name">>>;
// @ts-expect-error -- Type '"password"' is not assignable to type '"id" | "name" | "email"'.
pick(user, ["password"]);K is inferred from the array literal as the union "id" | "name", because its constraint (keyof T) is a union of literals. readonly K[] lets callers pass as const arrays too. The as on the empty object is the one honest lie: the loop fills in every key before it's returned.
Exercise 3: a typed get(obj, "a.b.c")
Write get(obj, path) where path is a dot-separated string. Only valid paths should be accepted, and the return type should be the type at that path.
function get(obj, path) {
// your code
}
const config = { db: { host: "localhost", port: 5432, tls: { enabled: true } }, name: "app" };
get(config, "db.port"); // number
get(config, "db.tls.enabled"); // boolean
get(config, "db.nope"); // should be an errorHints
- You need two helper types:
Paths<T>, the union of all valid path strings, andPathValue<T, P>, the type at a path. Paths<T>: for each string keyK, produceK | `${K}.${Paths<T[K]>}`. Mapping over keys and indexing with[keyof T & string]turns the object into a union.PathValue<T, P>: split withP extends `${infer Head}.${infer Rest}`and recurse.
Show the solution
type Expect<T extends true> = T;
type Equal<X, Y> =
(<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type Paths<T> = T extends object
? { [K in keyof T & string]: K | `${K}.${Paths<T[K]>}` }[keyof T & string]
: never;
type PathValue<T, P extends string> =
P extends `${infer Head}.${infer Rest}`
? Head extends keyof T
? PathValue<T[Head], Rest>
: never
: P extends keyof T
? T[P]
: never;
function get<T, P extends Paths<T>>(obj: T, path: P): PathValue<T, P> {
return path
.split(".")
.reduce<unknown>((current, key) => (current as Record<string, unknown>)[key], obj) as PathValue<T, P>;
}
const config = { db: { host: "localhost", port: 5432, tls: { enabled: true } }, name: "app" };
const port = get(config, "db.port");
const enabled = get(config, "db.tls.enabled");
type tests = [
Expect<Equal<typeof port, number>>,
Expect<Equal<typeof enabled, boolean>>,
Expect<Equal<Paths<typeof config>, "db" | "db.host" | "db.port" | "db.tls" | "db.tls.enabled" | "name">>,
];
// @ts-expect-error -- Argument of type '"db.nope"' is not assignable to parameter of type 'Paths<...>'.
get(config, "db.nope");▶ Try it in the TypeScript Playground
Things to mention: for a primitive, Paths gives never, and `${K}.${never}` is never, so leaves only contribute their own key. The runtime part can't prove the types, so there's one assertion at the end. Limits: arrays would add paths like "list.length", and very deep or recursive types can hit the compiler's recursion limit; in real code you'd cap the depth.
How to talk through a solution
Interviewers grade the process as much as the code. A simple script that works:
| Step | What you say |
|---|---|
| 1. Restate | "So pick takes an object and a list of its keys and returns just those keys, typed." |
| 2. Ask | "Should duplicate keys be allowed? Do we care about readonly arrays? Arrays as input?" |
| 3. Signature first | Write the types before the body: "The caller's experience is the signature." |
| 4. Examples | Write two calls: one that should compile and one that should fail. These are your tests. |
| 5. Implement | Keep the body simple. Say out loud where you use an assertion and why it's safe. |
| 6. Edge cases | never, empty inputs, unions, optional properties, any. Mention what you would add with more time. |
Recap
- Extra properties only fail on fresh object literals; structural typing allows them otherwise.
Object.keysisstring[], array indexing trusts you,JSON.parseisany: turn onnoUncheckedIndexedAccessand useunknownat boundaries.{}means non-nullish,typeof nullis"object",readonlyis shallow,as constis deep.- Assertions (
as) check nothing at runtime;anyspreads silently. - Methods are bivariant, conflicting discriminants make
never, numeric enums accept anynumber. - Live coding: signature first, examples as tests, then the body; talk the whole time. Finish with the mock interview.