skip to content

In TypeScript with strict enabled, given `class Dog extends Animal` and `interface Cell<T> { value: T; set: (next: T) => void }`, is `Cell<Dog>` assignable to `Cell<Animal>`, is `Cell<Animal>` assignable to `Cell<Dog>`, or neither — and why?

level: middleimportance: should knowfreq 40%

answer

  1. read and write in the same type
  2. two requirements that cannot both hold
  3. think about what the alias could store
  4. a cat in a container of dogs
  5. mutable containers cannot be substituted

basics

~10 s

Neither direction is allowed: Cell<T> is invariant because T appears in both an output position (value) and an input position (set). Covariance and contravariance both apply, and only the same type argument satisfies both.

solid answer

~50 s

Neither — `Cell<T>` is invariant. The `value` property is an output position, which on its own would make the type covariant and allow `Cell<Dog>` to flow into `Cell<Animal>`. The `set` property puts `T` in a parameter position, which on its own would make it contravariant and allow the opposite. Because both members must check, the two requirements cancel and only an identical type argument works. That is not the compiler being pedantic: if `Cell<Dog>` were assignable to `Cell<Animal>`, a caller holding the `Cell<Animal>` view could legally call `set(new Cat())`, and the original owner's next read of `value` would be typed `Dog` but hold a cat. Invariance is exactly the rule that mutable containers need, and the practical fix is to split the reading and writing halves into separate types rather than to reach for a cast.

go deeper

for a junior

Recall that a container you can both read from and write to cannot be swapped for a container of a wider or narrower element type, and that the compiler rejects both directions.

for a middle

Walk the member-by-member check out loud: the read member wants one direction, the write member wants the other, and both must pass. Then give the concrete unsafe write that the rule prevents.

for a senior

Recognise the regression pattern in review — a covariant type that turns invariant the moment a consuming member is added — and steer the fix toward splitting read and write views instead of casting the error away.

for a principal

Decide deliberately whether a widely shared generic is a producer, a consumer, or both, because that choice fixes its variance and therefore how freely it can travel through every API your teams build on it.

## The short answer, then the reason Neither assignment compiles. `Cell<T>` is **invariant** in `T`, meaning the only `Cell` assignable to `Cell<Animal>` is another `Cell<Animal>` (or a type mutually assignable with it). ## Two members pulling in opposite directions Variance is decided by where the type parameter appears, and `Cell` puts it in both kinds of place at once: ```ts interface Cell<T> { value: T; // output position -> wants covariance set: (next: T) => void; // input position -> wants contravariance } ``` Assignability between two object types is checked member by member, and **every** member must succeed. Test `Cell<Dog>` against `Cell<Animal>`: - `value`: source has `Dog`, target requires `Animal`. `Dog` is assignable to `Animal`, so this member passes. - `set`: source has `(next: Dog) => void`, target requires `(next: Animal) => void`. Parameter positions are checked contravariantly under `strict`, so this passes only if `Animal` is assignable to `Dog` — it is not. This member fails. One failing member is enough, so the whole assignment is rejected. Now test the other direction, `Cell<Animal>` against `Cell<Dog>`: - `value`: source has `Animal`, target requires `Dog`. `Animal` is not assignable to `Dog`. Fails immediately. Both directions fail, each for a different member. That is the definition of invariance: the covariant requirement and the contravariant requirement can only both hold when the type arguments are the same. ## Why the rule is protecting you Invariance on a mutable container is not bureaucracy — it is the single most important soundness rule in the whole area. Imagine the first assignment were permitted: ```ts declare const dogCell: Cell<Dog>; const asAnimal: Cell<Animal> = dogCell; // suppose this were allowed asAnimal.set(new Cat()); // perfectly legal for a Cell<Animal> dogCell.value.bark(); // typed Dog, actually a Cat ``` Every individual step is legitimate under its own type. The alias created a hole through which a `Cat` entered a container the original owner still believes holds only dogs, and the failure surfaces far away, at a call that the checker had already blessed. Because TypeScript's types are erased, nothing at runtime will catch it — no cast happens, no check runs, the property access simply finds no `bark` method and throws. A mutable container is simultaneously a producer and a consumer, and there is no way to substitute a different element type that is safe for both roles at once. Invariance is the only correct answer. ## Recognising it in real code The pattern to watch for is a type that was covariant until somebody added a writing member. A `Repository<T>` with only `findAll(): T[]` is covariant and flows freely; the day a `save: (item: T) => void` lands on it, every call site that relied on passing `Repository<Dog>` where `Repository<Animal>` was wanted starts failing, and the diff that broke them touched neither call site. Recognising the cause as a variance change — rather than a mysterious assignability regression — is most of the value of understanding this topic. The same shape appears in setters and getters, in event buses that both publish and subscribe, in caches, and in any options object holding both a default value of type `T` and a callback taking a `T`. ## What to do about it The principled fix is to stop requiring the whole invariant type where only half of it is used. If a function only reads, ask for a type whose parameter appears only in output positions; if it only writes, ask for one whose parameter appears only in input positions. The full read-write type can extend both, so existing values still satisfy every API, and each caller gets whichever variance its own usage actually supports. The unprincipled fixes are worth naming so you can reject them in review. An `as` assertion silences the error and reintroduces exactly the cat-in-the-dog-cell hazard, with no runtime check anywhere. Widening the container to `Cell<any>` does the same thing more quietly. And an explicit variance annotation cannot help either: annotations state the variance a type already has and are verified against its members — they cannot grant a mutable container a variance it does not possess. One related nuance is deliberately out of scope here: writing the consumer as a method shorthand rather than a function-typed property changes how its parameters are compared, which is a separate topic in its own right.

  • If you delete the `set` member, what happens to the assignability?
    `Cell<T>` becomes covariant, so `Cell<Dog>` starts flowing into `Cell<Animal>`. That is the practically important observation: variance is a property of the members, so adding or removing one consuming member silently changes which assignments compile across the whole codebase, with no change at any call site.
  • Would marking `value` as `readonly` make the type covariant?
    No. `readonly` stops writes through the property itself, but `set` still puts `T` in a parameter position, so the contravariant requirement remains and the type stays invariant. Variance depends on every position the parameter occupies, not on the modifiers of one member.
  • Why does an `as` cast make the error disappear without making the code safe?
    Because an assertion is a compile-time instruction to stop checking, not a conversion. Nothing is verified and no code is emitted, so the alias still lets a cat be written into a container the owner reads as dogs. The failure just moves to a later property access, with no clue pointing back at the cast.

saying these in an interview costs you the question

  • Says one direction works because Dog extends Animal
  • Thinks the compiler will check the element type at runtime
  • Claims a cast is a legitimate fix for the error
  • Believes readonly on the getter alone restores covariance
  • Treats invariance as a compiler limitation rather than a soundness rule

context