Capítulo 28 de 36

Chapter 28: Type Compatibility

Core Idea

TypeScript compatibility is structural ("does the shape match?"), not nominal ("does it explicitly declare implementing this type?") — a design chosen because JavaScript code leans heavily on anonymous objects and function expressions, which a nominal system would fight against constantly.

Key Concepts

  • Structural subtyping basics: x is compatible with y if y has at least the members x requires — a class or object literal never needs to declare implements SomeInterface to satisfy that interface; having the right shape is enough. The same rule governs assignment and function-call argument passing.
  • Extra properties are fine on a pre-existing variable, but not on a fresh object literal: assigning a variable with extra properties to a differently-typed target is allowed (only the target's declared members are checked); assigning an object literal directly triggers excess property checks (see Object Types), which is a separate, stricter rule.
  • Soundness trade-off: TypeScript deliberately allows some assignments that aren't fully provable-safe at compile time, because the alternative would reject extremely common, harmless JavaScript patterns. This chapter is largely a tour of exactly where and why.
  • Function compatibility — parameters: a function is assignable to a target if every one of its parameters has a compatible counterpart in the target — parameter names are irrelevant, only position and type matter. Fewer parameters is fine (you can assign (a: number) => void where (a: number, b: string) => void is expected) — mirroring the common JS pattern of a callback that ignores extra arguments (like Array.prototype.forEach's index/array params). The reverse (more required parameters than the target expects) is an error.
  • Function compatibility — return type: the source's return type must be a subtype of the target's return type (covariant, and not relaxed the way parameters are) — a function returning { name: string } is assignable to a target expecting { name: string; location: string }... wait, actually the reverse: a function returning the richer shape is assignable where the leaner shape is expected, not vice versa.
  • Function parameter bivariance (a deliberate unsoundness): parameter compatibility is checked in either direction — source-to-target or target-to-source — because in practice this rarely causes real bugs and unlocks common event-handler patterns (e.g. registering a (e: MouseEvent) => void handler where a generic (e: Event) => void is technically expected). Turn on strictFunctionTypes to make TypeScript enforce the stricter, sound direction only.
  • Optional and rest parameters are interchangeable for compatibility purposes: extra optional source parameters, or target optional parameters missing from the source, are not errors; a rest parameter is treated like an infinite run of optional parameters. This directly supports "callback invoked with an unpredictable-to-the-type-system but predictable-to-the-programmer number of arguments" patterns.
  • Overloaded functions: every overload signature on the target must be matched by a compatible signature on the source — ensures the source function is callable in every way the target function could be called.
  • Enums: numbers and enums are mutually compatible, but two different enum types are not compatible with each other even if their underlying numeric values line up.
  • Classes: compared structurally like interfaces, but only instance members matter — static members and constructor signatures are ignored for compatibility, so two unrelated classes with the same instance shape (but different constructor signatures) are still mutually assignable.
  • private/protected members break pure structural comparison: a class with a private/protected member is only compatible with another type that has a matching private/protected member originating from the same declaration (i.e. actually inherited from the same base) — this is what makes a subclass assignment-compatible with its own superclass, while rejecting an unrelated class that merely happens to have an identically-shaped private field.
  • Generics and compatibility: for two instantiations of the same generic type, if the type parameter isn't actually used anywhere differentiating in the type's members, the instantiations are still compatible even with different type arguments (an empty interface Empty<T> {} is compatible across Empty<number>/Empty<string>); once the parameter is actually used in a member (interface NotEmpty<T> { data: T }), differing type arguments make the instantiations incompatible, exactly like two unrelated concrete types would be. For uninstantiated generic signatures (e.g. two generic function types being compared to each other), compatibility is checked by substituting any for every unspecified type parameter first.
  • Subtype vs. assignment compatibility: two related-but-distinct notions in the spec — assignment compatibility is subtype compatibility plus extra allowances to/from any and between enums and their underlying numbers. In practice, assignment compatibility is what governs everywhere in the language, including implements/extends clauses.
  • any / unknown / object / void / undefined / null / never assignability, summarized:
    • Everything is assignable to itself.
    • any and unknown accept the same things (everything), but differ on the outbound side: any is assignable to anything, while unknown is assignable to nothing except any (and itself) — this asymmetry is exactly what makes unknown the safe version of any.
    • never and unknown are near-opposites: never is assignable to everything (it represents "no value could ever be here," so it trivially satisfies any target), while nothing is assignable to never except never itself.
    • void accepts almost nothing back and is accepted by almost nothing, except any/unknown/never — and, notably, undefined is always assignable to void regardless of strictNullChecks.
    • With strictNullChecks off, null/undefined behave close to never — assignable into nearly anything, accepting almost nothing (mutually assignable to each other, though). With it on, they instead behave close to void — not assignable to or from most things, with the same any/unknown/void exceptions.

Code Examples

interface Pet { name: string }
class Dog { name: string = ""; }
let pet: Pet = new Dog(); // OK — Dog is never declared to `implements Pet`
  • What it demonstrates: structural typing accepting Dog wherever Pet is expected purely because the shape matches, with no explicit implements relationship required.
function listenEvent(handler: (e: { timestamp: number }) => void) {}
interface MouseEvent { timestamp: number; x: number; y: number }
listenEvent((e: MouseEvent) => console.log(e.x)); // allowed (bivariant params), unsound in theory, common in practice
  • What it demonstrates: parameter bivariance permitting a more specific handler type where a general one is technically expected — the pattern strictFunctionTypes can optionally forbid.

Key Takeaways

  1. Never rely on implements/extends to make two types compatible — compatibility comes purely from matching structure; those clauses are for documentation/checking, not for granting compatibility that shape alone wouldn't already provide.
  2. Turn on strictFunctionTypes if a codebase needs full parameter-type soundness in function comparisons — the default bivariant behavior is a deliberate, JS-pattern-motivated relaxation, not a bug.
  3. private/protected members are the one place structural typing breaks down for classes — they require a shared declaration origin, not just a matching shape, which is what makes subclass-to-superclass assignment work while blocking unrelated lookalike classes.
  4. Remember the unknown/never asymmetry: unknown accepts everything but gives back to almost nothing; never is accepted by nothing but gives back to everything.

Connects To

  • Object Types: excess property checks, the stricter sibling rule for object literals specifically.
  • More on Functions: void/unknown/never/Function types referenced throughout this chapter's assignability discussion.
  • Generics: the "type parameter must actually differentiate members" rule for generic compatibility.
  • Classes — Members, Heritage & Visibility: private/protected semantics this chapter's compatibility rule depends on.