A shared `Store<T>` type in your codebase both reads and writes `T`, so it is invariant and teams keep hitting errors passing a `Store<Dog>` to code that wants a `Store<Animal>`. How do you weigh splitting it into read and write views, adding variance annotations, and letting callers cast?
answer
- the type is doing two jobs
- ask for the capability you use
- annotations assert, they do not grant
- one wrapper, never a cast per call site
- variance is part of a published contract
basics
~20 sSplit the type: give the reading half a parameter that appears only in output positions and the writing half one that appears only in input positions, then have APIs ask for the view they use. Variance annotations only assert existing variance, and casts hide the hazard.
solid answer
~50 sOnly one of the three options is a real fix. Invariance is a fact about the members, so the way out is to stop demanding the whole type where half of it is used: extract a `ReadableStore<out T>` and a `WritableStore<in T>`, have `Store<T>` extend both, and change each API to accept the narrowest view it actually needs. Existing values still satisfy everything, and read-only consumers immediately get covariant assignability. Variance annotations cannot help here — the compiler verifies them against the members, so `out` on a type that writes `T` is a compile error, not an override. Casts are the worst option: they silence the checker without any runtime check, so a wrong element type surfaces later, far from the cast. I would treat the split as an API design change with the usual costs — more surface area, a codemod at call sites — and weigh those against how often the assignability friction actually bites.
code
typescript · 15 linesclass Animal { name = ""; }
class Dog extends Animal { bark() {} }
interface ReadableStore<out T> { read: () => T }
interface WritableStore<in T> { write: (value: T) => void }
interface Store<T> extends ReadableStore<T>, WritableStore<T> {}
function describeAll(source: ReadableStore<Animal>): string {
return source.read().name;
}
declare const dogStore: Store<Dog>;
describeAll(dogStore); // OK: only the covariant view is required
// const asAnimalStore: Store<Animal> = dogStore; // still rejected: Store is invariantgo deeper
Know that when a generic both reads and writes its parameter you cannot pass it where a different element type is expected, and that the fix lives in the type's design rather than at the call site.
Explain why the split works: the read view uses the parameter only in output positions and the write view only in input positions, so each is safely substitutable while the combined type stays invariant.
Drive the migration safely — introduce the views, have the existing type extend them so nothing breaks, then narrow signatures incrementally — and hold the line on casts during review.
Own the contract: decide whether the shared type is a producer, a consumer, or both, recognise that changing that later breaks consumers who did not change, and weigh the added API surface against the chronic cross-team friction it removes.
## Frame the problem correctly first The errors are a symptom; the cause is that a single type is doing two jobs. `Store<T>` is invariant because it both produces and consumes `T`, and no substitution of the element type is safe for both roles at once. Any "fix" that leaves the members alone is therefore either an error suppression or a compile error. Saying that out loud is most of the answer. ## Option one: split producer and consumer views ```ts interface ReadableStore<out T> { read: () => T } interface WritableStore<in T> { write: (value: T) => void } interface Store<T> extends ReadableStore<T>, WritableStore<T> {} function describeAll(source: ReadableStore<Animal>) { return source.read().name; } declare const dogStore: Store<Dog>; describeAll(dogStore); // fine: only the covariant view is required ``` The full `Store<T>` remains invariant — correctly so — but no caller has to demand it. A function that only reads asks for `ReadableStore<Animal>` and accepts a `Store<Dog>`; a function that only writes asks for `WritableStore<Dog>` and accepts a `Store<Animal>`. Nothing became less safe: each caller gets exactly the variance its own usage supports. This is the principled answer, and it is a restatement of interface segregation with variance as the reason. The costs are real, though, and a principal-level answer names them: three types where there was one, a decision at every parameter about which view to require, a migration across call sites, and a documentation burden so future contributors do not go back to typing everything as `Store<T>` out of habit. ## Option two: variance annotations This is the tempting non-answer. Annotations do not grant variance — the compiler checks them against the members and errors when they disagree, so `out T` on a type with a `write` member simply does not compile. Their real uses are stating intent on a stable public type and shortening the checker's structural comparison of large generic types, and the second one has its own edge cases. Understand what they are for, and reject them as a remedy for invariance. ## Option three: let callers cast An `as` assertion is not a conversion. Nothing is checked, nothing is emitted, and the hazard invariance was predicting becomes real: through the widened alias, a value of the wrong element type can be written into a store whose owner still reads it as the narrow type, and the failure appears at some later property access with no link back to the cast. As a policy it is worse than it looks, because casts spread — once one call site has a blessed cast, the pattern is copied, and the type's contract quietly stops meaning anything. If a cast is genuinely unavoidable at a boundary, it belongs in one wrapper function with a comment stating the invariant that makes it safe, not at every call site. ## How I would actually decide Start by measuring the friction. If the assignability errors come from a handful of call sites in one team's code, the cheapest correct move is often to change those signatures to take the exact instantiation they need, and leave the shared type alone. The split earns its keep when the friction is chronic and crosses team boundaries — when several consumers only read, and are being forced to depend on a write capability they never use. Second, look at who owns the type. For a type published to other teams, variance is part of the contract, and changing it later is a breaking change dressed up as a refactor: consumers who were passing `Store<Dog>` around start seeing errors from a diff they did not make. Getting the read/write split right early is much cheaper than retrofitting it, which argues for doing it at design time even when the pain is not yet acute. Third, consider what the type will grow into. Types that both read and write tend to accumulate more of both, so the invariance is permanent and the pressure will keep rising. If the roadmap says the type is heading that way, split now. ## Getting the migration right A split lands well when the full type still satisfies every old signature, so introduce the two views first and have the existing type extend them, without changing a single caller. Then move signatures to the narrower view opportunistically — each such change is independently safe, since every existing argument already satisfies the narrower requirement. Ban the cast in review during the migration; otherwise it becomes the path of least resistance, and the split never finishes. ## The line to hold Invariance on a read-write generic is the type system being right. The design question is never how to defeat it; it is which capability each API actually needs, and whether the codebase is better served by expressing that precisely.
- Does splitting into read and write views make the code less type-safe anywhere?No. The full read-write type still exists and is still invariant; the split only lets callers require less. A function accepting the read-only view can only read, so widening the element type there is provably safe. Safety is unchanged — precision improves, and each API now states which capability it actually uses.
- When is changing a call site's signature to the exact instantiation the better move than splitting the type?When the friction is local. If a couple of functions in one team's code want `Store<Animal>` but only ever receive `Store<Dog>`, typing them for the concrete instantiation is a two-line change with no ripple. The split is for chronic, cross-team pressure where many consumers only read.
- If a cast is genuinely unavoidable at an integration boundary, how do you contain it?Put it in exactly one adapter function whose name says what it does, document the invariant that makes it safe, and validate at that boundary if the data is external. One reviewed cast with a stated precondition is defensible; the same cast copied across call sites erases the type's meaning and leaves no place to fix it later.
saying these in an interview costs you the question
- Suggests `out T` to make the read-write type covariant
- Treats a cast as a normal fix for assignability errors
- Widens the element type to any to stop the errors
- Assumes the split weakens type safety
- Ignores that variance is part of a published API contract