Tricky TypeScript Questions
These questions separate developers who have memorized syntax from those who understand how the type system actually behaves. They focus on subtle, easy-to-get-wrong corners: structural compatibility, variance of functions and arrays, what unknown means in a catch clause, how literal types widen, and the surprising places never shows up. Each answer explains the why, because in an interview the reasoning matters more than the verdict.
Subtle behavior
Why does this object with an extra property fail on a literal but pass on a variable?
Excess property checks only apply to fresh object literals assigned directly. Assigning through an intermediate variable bypasses the check because structural typing takes over.
interface Opts { width: number; }
// const a: Opts = { width: 1, height: 2 }; // ❌ excess property
const tmp = { width: 1, height: 2 };
const b: Opts = tmp; // ✅ no excess check — tmp is a wider but compatible type
The literal check is a heuristic to catch typos; it is intentionally not part of structural assignability.
What is type widening, and how do you prevent it?
let and mutable contexts widen literal types to their base type, while const keeps the literal. Use as const to freeze deep literal types.
let a = "hello"; // type: string (widened)
const b = "hello"; // type: "hello" (literal)
const config = { mode: "dark" }; // mode: string
const frozen = { mode: "dark" } as const; // mode: "dark", readonly
Widening is why a const object’s string property is still string — only the binding is const, not the values.
Why is unknown the type of the variable in a catch clause?
Under modern TypeScript with useUnknownInCatchVariables (on with strict), the caught value is unknown because any value can be thrown — not just Error. You must narrow before using it.
try {
risky();
} catch (err) {
// err: unknown
if (err instanceof Error) console.log(err.message);
}
This prevents the unsafe assumption that every thrown value has a .message.
Are TypeScript arrays covariant or invariant? Why is that unsound?
Arrays are covariant in their element type, which is convenient but unsound, because arrays are mutable. The compiler allows assigning Dog[] to Animal[], but writing through that reference can corrupt the original.
class Animal {}
class Dog extends Animal { bark() {} }
const dogs: Dog[] = [new Dog()];
const animals: Animal[] = dogs; // allowed (covariant)
animals.push(new Animal()); // compiles, but dogs now holds a non-Dog!
TypeScript accepts this tradeoff for ergonomics. readonly arrays are safely covariant.
How does function parameter variance work?
By default function parameters are bivariant (for method compatibility), but under strictFunctionTypes standalone function types are contravariant in their parameters. A function accepting a wider parameter is assignable to one expecting a narrower parameter.
type Handler = (a: Animal) => void;
const h: Handler = (a: Dog) => a.bark(); // ❌ under strictFunctionTypes
const ok: Handler = (a: Animal) => {}; // ✅ wider param accepted
Returns are covariant; parameters are contravariant.
Where does never show up unexpectedly?
never is the element type of empty arrays without annotation, the result of impossible intersections, and the value in an exhausted switch. Filtering a union with a conditional type can also collapse a branch to never.
type T = string & number; // never — impossible intersection
const arr = []; // never[] until something is pushed (with noImplicitAny)
Seeing never unexpectedly usually means a type was over-narrowed.
What is the difference between void and undefined as return types?
A void return means “ignore whatever is returned” — a void-typed callback may actually return a value, which is discarded. An undefined return strictly requires returning undefined or nothing.
type Cb = () => void;
const cb: Cb = () => 42; // ✅ allowed; return value ignored
const arr = [1, 2, 3];
arr.forEach((x) => doStuff(x)); // forEach takes a void callback
This rule is what lets arr.push(x) work as a callback even though it returns a number.
Why can two structurally identical types still feel “different”?
They are not different to the compiler — structural typing means identical shapes are interchangeable. But private/protected members and class brands are tracked nominally, so two classes with the same public shape but distinct private fields are not assignable.
class A { private x = 1; }
class B { private x = 1; }
const a: A = new B(); // ❌ Error — private members are nominal
What does keyof any evaluate to?
string | number | symbol — the set of all possible property key types. This is why Record<K, V> constrains K extends keyof any.
type Keys = keyof any; // string | number | symbol
Why might a narrowed type “reset” after a callback?
TypeScript cannot guarantee a variable stays narrowed across a function boundary because the callback could run later, so it discards narrowing for non-const outer variables captured in closures.
function f(x: string | null) {
if (x) {
setTimeout(() => x.toUpperCase()); // ❌ x is string | null again
}
}
Assign to a const inside the guard to preserve the narrowed type.
Best Practices
- Explain the reason for each quirk; interviewers reward “why”, not just “yes/no”.
- Tie excess property checks to “fresh literal only” — a frequent gotcha.
- Mention
strictFunctionTypeswhen discussing parameter variance. - Call array covariance “convenient but unsound” to show you understand the tradeoff.
- Always narrow
catchvariables; never assumeError. - Use
as constto stop widening when you want literal types. - Note that
privatemembers make classes nominal despite structural typing.