Tricky TS Interview Questions
HardTypeScript interviews often include tricky questions that test deep understanding of the type system. These cover structural typing vs nominal typing, type widening and narrowing edge cases, variance (covariance and contravariance), template literal type manipulation, and subtle differences between similar-looking constructs. Mastering these questions demonstrates expert-level TypeScript knowledge.
Interactive Visualization
Key Points
- TypeScript uses structural typing — two types are compatible if they have the same shape
- Type widening occurs when TS infers a broader type than the literal value
- Covariance: arrays of subtypes are assignable to arrays of supertypes (read-only is safe)
- Contravariance: function parameter types work in reverse — wider params accept narrower
- Excess property checking only applies to object literals, not variables
- Declaration merging lets you extend interfaces across multiple declarations
- The `satisfies` operator validates a type without changing the inferred type
Code Examples
Structural Typing Gotchas
interface Point2D { x: number; y: number } interface Point3D { x: number; y: number; z: number } // Structural typing: Point3D has all properties of Point2D const p3: Point3D = { x: 1, y: 2, z: 3 } const p2: Point2D = p3 // OK — Point3D is a structural subtype // But excess property checking catches extra properties on literals // const p2b: Point2D = { x: 1, y: 2, z: 3 } // Error on object literal! // This works because it is assigned from a variable, not a literal const temp = { x: 1, y: 2, z: 3, w: 4 } const p2c: Point2D = temp // OK — no excess property check on variables // Nominal-ish typing with branded types type USD = number & { __brand: 'USD' } type EUR = number & { __brand: 'EUR' } function payInUSD(amount: USD): void { /* ... */ } const dollars = 100 as USD payInUSD(dollars) // OK // payInUSD(100) // Error — number is not USD // payInUSD(100 as EUR) // Error — EUR is not USD
TypeScript is structurally typed, meaning types are compatible based on their shape, not their name. Branded types simulate nominal typing by adding a phantom property that prevents accidental mixing.
Variance & Function Types
class Animal { name = 'animal' } class Dog extends Animal { breed = 'unknown' } class Labrador extends Dog { color = 'golden' } // Covariance — subtypes can be assigned to supertypes (return types) type AnimalFactory = () => Animal type DogFactory = () => Dog const dogFactory: DogFactory = () => new Dog() const animalFactory: AnimalFactory = dogFactory // OK — Dog extends Animal // Contravariance — supertypes accepted for parameter types type AnimalHandler = (a: Animal) => void type DogHandler = (d: Dog) => void const handleAnimal: AnimalHandler = (a) => console.log(a.name) // With strictFunctionTypes, this is an error: // const handleDog: DogHandler = handleAnimal // OK in --strict! // Bivariance trap (method syntax vs function syntax) interface Arr { // Method syntax — bivariant (less safe) forEach(cb: (item: Dog) => void): void // Function property syntax — contravariant (safer) map: (cb: (item: Dog) => void) => void }
Variance rules determine when types are substitutable. Return types are covariant (subtypes OK), parameter types are contravariant (supertypes OK). Method syntax in interfaces is bivariant for historical reasons.
Type Widening & Narrowing Edge Cases
// Widening: let declarations widen to the base type let x = 'hello' // string (widened) const y = 'hello' // 'hello' (literal — no widening) // Fresh object literals are widened let obj = { x: 1 } // { x: number } — not { x: 1 } const objConst = { x: 1 } // still { x: number } — only value is const! const objAsConst = { x: 1 } as const // { readonly x: 1 } // Narrowing gotcha: null check in callbacks function processItems(items: string[] | null): void { if (items === null) return // items is string[] here items.forEach(item => { // items is STILL narrowed to string[] in sync callbacks console.log(item, items.length) }) // But in async callbacks, narrowing is lost! setTimeout(() => { // items could theoretically be reassigned (if let) // TypeScript may or may not narrow here depending on mutability }, 100) } // Exhaustive narrowing with never type Shape = { kind: 'circle'; r: number } | { kind: 'square'; s: number } function area(shape: Shape): number { switch (shape.kind) { case 'circle': return Math.PI * shape.r ** 2 case 'square': return shape.s ** 2 default: { const _exhaustive: never = shape // Error if a case is missing return _exhaustive } } }
Type widening and narrowing edge cases are common in interviews. Understanding when TypeScript widens (let declarations, object properties) vs preserves literals (const, as const) is key.
Common Mistakes
- Assuming TypeScript uses nominal typing — it uses structural typing
- Not understanding that excess property checking only applies to object literals
- Confusing covariance and contravariance in function type assignments
- Forgetting that `const` declarations only make the binding immutable, not the value
- Not using branded types when you need nominal-like distinctions between number/string types
Interview Tips
- Explain structural vs nominal typing with a concrete example
- Know variance rules — covariance for return types, contravariance for parameters
- Be ready to explain type widening with let vs const vs as const
- Mention branded types as a workaround for nominal typing needs
- Practice the exhaustive switch with `never` pattern — it comes up frequently