Tricky TS Interview Questions

Hard

TypeScript 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

Related Concepts