Variance, Assignability, and Structural Typing
What makes one type assignable to another, co- and contravariance in function types, method bivariance and the unsoundness it permits.
What makes one type assignable to another, co- and contravariance in function types, method bivariance and the unsoundness it permits.
RawPhoto extends Photo. A function takes Photo[]. Passing
a RawPhoto[] is an error, and the message explains nothing
useful about why.
Meanwhile a function typed (photo: Photo) => void accepts one
declared (photo: RawPhoto) => void, which is the same
relationship in the opposite direction and is allowed. By the
end of this lesson you will know the rule that governs both,
where this language deliberately breaks it, and how to write
signatures that do not run into it.
The base rule: A is assignable to B if A has everything
B requires.
type Photo = { id: string; name: string };
type Named = { name: string };
const photo: Photo = { id: "a", name: "dawn.jpg" };
const named: Named = photo; // fine - it has a nameNo declaration of a relationship is needed. This is structural typing, and it is why an interface can describe something written before the interface existed.
The counterpart is that two identical shapes are one type:
type Metres = number;
type Feet = number;
const distance: Metres = 100;
const height: Feet = distance; // fine - and probably a bugWhich is what branding, from the practice course, exists to defeat.
Covariance is "the same direction". A container of a subtype is a container of the supertype, when you can only read:
type Reader<T> = { get(): T };
const rawReader: Reader<RawPhoto> = ...;
const photoReader: Reader<Photo> = rawReader; // fineEvery RawPhoto it produces is a Photo, so a consumer
expecting Photo is satisfied.
Contravariance is "the opposite direction". A consumer of a supertype is a consumer of the subtype:
type Writer<T> = { set(value: T): void };
const photoWriter: Writer<Photo> = ...;
const rawWriter: Writer<RawPhoto> = photoWriter; // fineSomething that accepts any Photo certainly accepts a
RawPhoto. Note this goes the other way — that is the part
people find counterintuitive, and it is why a handler typed for
a broad event works where a narrow one is expected.
The rule underneath both:
Same direction as the type.
A Reader<RawPhoto> is a Reader<Photo>, because everything
it hands you is a Photo.
Function return types live here.
Opposite direction.
A Writer<Photo> is a Writer<RawPhoto>, because something
accepting any Photo certainly accepts a RawPhoto.
Function parameters live here, which is why a handler typed for a broad event works where a narrow one is expected.
Now the opening error.
An array can be read and written, so it is both an output and
an input position — which means neither direction is safe. If
RawPhoto[] were assignable to Photo[], the function above
would push a plain Photo into an array everyone else believes
holds RawPhoto, and the failure would surface much later.
So arrays are invariant: RawPhoto[] is not Photo[], and
Photo[] is not RawPhoto[].
Bad — asking for a mutable array you only read.
Good — asking for read-only access.
The first version demands a writable array because that is the default, so the checker must assume it might write — and rejects a caller whose array it could corrupt. The rejection is correct and the demand was not: the function only reads.
readonly T[] has no push, so it is an output position only,
so it is covariant. One keyword, and the error disappears
because the promise changed.
This is the "ask for the least you need" rule from the
foundations course, with the mechanism now visible. Prefer
readonly T[] on every parameter you do not modify.
Method parameters are checked bivariantly — assignable in either direction — which is deliberately unsound:
strictFunctionTypes fixes this for function-typed properties
but not for method shorthand, because tightening it would
break large amounts of existing code — arrays are the standard
example, where Array<RawPhoto> would stop being usable as
Array<Photo> in read positions.
The practical rule: write callbacks as properties, not methods, when you want them checked properly:
That one-character difference — handle(event) versus
handle: (event) => — changes how strictly the checker treats
it, which is worth knowing because nothing about the syntax
suggests it.
Structural typing says extra properties are fine:
But an object literal written directly gets the excess property check:
Because a literal with an unexpected key is nearly always a typo, and there is no other explanation for it. Assigning through a variable removes the check — which is why the foundations course warned about a misspelled optional flag passed via a variable.
The check does not apply to unions in the way people expect:
The literal satisfies at least one member, and the extra
property belongs to the other. That is a real gap when modelling
mutually exclusive shapes, which is why the practice course used
b?: never for that case.
Four habits that avoid variance errors rather than fighting them.
readonly on parameters you do not modify. The single most
effective one, and it is what makes covariance available.
Separate reading from writing in an interface. A type doing both is invariant in its parameter and cannot be substituted in either direction:
Splitting it into Reader<T> and Writer<T> gives each the
variance it deserves, and callers depend only on the half they
use.
Callbacks as properties, per the bivariance section.
Narrow returns, broad parameters. Return the most specific type you produce; accept the most general one your body handles. That is the same advice as the practice course's typing lesson, and variance is why it works — it maximises what callers can pass and what they can do with the result.
You can now read an assignability error rather than working
around it, and you know the two places this language is
deliberately permissive. The practical change is readonly on
parameters — it removes most variance errors people meet, and it
removes them by making a more honest promise.
Next is Function Overloads and Call Signatures, which covers describing a function that legitimately has several shapes, and why a union or a generic is usually better than overloading.
Before you move on, take a function taking T[] that only
reads, change it to readonly T[], and try passing an array of
a subtype. It worked before only if the subtype happened not to
be involved; now it works because the signature says what the
function actually does.
ASSIGNABILITY IS STRUCTURAL
A is assignable to B if A has everything B requires
no declaration needed - which is why interfaces can describe
code written before them
two identical shapes are ONE type -> brand them if that matters
VARIANCE
covariant output positions Reader<Raw> -> Reader<Photo>
contravariant input positions Writer<Photo> -> Writer<Raw>
a function is contravariant in parameters, covariant in return
INVARIANT
Array<T> is read AND written -> neither direction is safe
RawPhoto[] is not Photo[], and Photo[] is not RawPhoto[]
readonly Photo[] output only -> covariant
put readonly on every parameter you do not modify
THE UNSOUNDNESS
method shorthand is checked BIVARIANTLY
handle(event: Event): void loose
handle: (event: Event) => void strict, under strictFunctionTypes
one character, and it changes how strictly it is checked
kept for compatibility - arrays depend on it
EXCESS PROPERTIES
extra properties are fine structurally
an object LITERAL assigned directly is checked - a stray key
is a typo
assigning through a variable removes the check
a literal satisfying one member of a union can carry the
other's fields -> use `b?: never` for exclusive shapes
VARIANCE ANNOTATIONS
interface R<out T> / <in T>
no behaviour change; they let the checker skip work
a PERFORMANCE tool, and documentation
DESIGNING AROUND IT
readonly parameters
split Reader<T> and Writer<T> rather than one invariant Store<T>
callbacks as properties
narrow returns, broad parametersfunction addPlaceholder(photos: Photo[]): void {
photos.push({ id: "x", name: "placeholder.jpg" });
}
const raws: RawPhoto[] = [rawPhoto];
addPlaceholder(raws);function totalSize(photos: Photo[]): number {
return photos.reduce((sum, p) => sum + p.size, 0);
}
totalSize(rawPhotos); // errorfunction totalSize(photos: readonly Photo[]): number {
return photos.reduce((sum, p) => sum + p.size, 0);
}
totalSize(rawPhotos); // finetype Handler = {
handle(event: Event): void;
};
const mouseHandler: Handler = {
handle(event: MouseEvent): void { ... }, // accepted
};
mouseHandler.handle(new KeyboardEvent("keydown"));
// compiles; the handler reads mouse-specific propertiestype Handler = {
handle: (event: Event) => void; // checked contravariantly
};const candidate = { id: "a", name: "dawn.jpg", extra: 1 };
const photo: Photo = candidate; // fineconst photo: Photo = { id: "a", name: "dawn.jpg", extra: 1 };
// Object literal may only specify known properties.type A = { a: string };
type B = { b: string };
const value: A | B = { a: "x", b: "y" }; // allowedtype Store<T> = {
get(id: string): T | undefined;
save(item: T): void;
};