Type systems and inference / personal lessonsLesson 10 of 12
Type systems · Lesson 10

Derive compatibility from reading and writing

Function inputs reverse the subtype direction; outputs preserve it. Read-write cells need both.

By the end: justify covariance, contravariance, and invariance with permitted operations.

A producer can return something more specific

A read-only producer of Cat can satisfy a request for a producer of Animal. Every result is still an Animal. This preserves the subtype direction and is called covariance. A consumer reverses it: something able to accept any Animal can safely accept Cats. This is contravariance.

Do not infer variance just from angle-bracket syntax. Ask which operations the interface permits. The same type parameter might appear as output, input, or both. Language rules also depend on ownership, lifetimes, and other features; our finite Animal example isolates the substitution argument.

Test replacement promises with concrete callers

Variance describes how changing a type inside an interface affects compatibility of the whole interface. A type constructor is a description with a type-sized slot, such as Producer<T>, where T names the result type. It is not necessarily an operation that builds a runtime value. A Producer<Cat> promises that each permitted read returns Cat. A Producer<Animal> promises only Animal. Because every Cat is Animal, supplying the first producer wherever the second is required preserves each read promise. Covariance is the name for this preserved direction, not a claim about implementation or storage.

A Consumer<T> instead permits supplying T as input. Suppose a caller requires a Consumer<Cat>. The caller may provide a Cat, but it has no permission from that interface to provide a Dog. A Consumer<Animal> can accept that Cat because it accepts any Animal. It can therefore replace the required Cat consumer. This reverses the parameter relation: Cat <: Animal, yet Consumer<Animal> can supply Consumer<Cat>. The reversal is contravariance. A Consumer<Cat> cannot generally replace Consumer<Animal>, because the latter permits a caller to supply a Dog that the former need not accept.

Reads and writes test different values

ReplacementProducer result testConsumer input test
Cat parameter supplies Animal parameterCora is Animal: passesCaller may send Dune: fails
Animal parameter supplies Cat parameterMay return Dune: failsCaller sends Cora: passes
Producer tests start with possible returned values. Consumer tests start with inputs authorized by the required interface.

Use the same paper witnesses as before, Cora the Cat and Dune the Dog. For a producer replacement, ask what it might return. A Cat producer returning Cora satisfies Animal, while an Animal producer returning Dune does not satisfy a Cat requirement. For a consumer replacement, ask what a permitted caller might send. A caller expecting an Animal consumer may send Dune, which defeats a replacement accepting only Cat. These are counterexamples to universal replacement claims. Finding one friendly call or result does not establish safety for every input or output the interface permits.

The word "accept" in these arguments means the input is allowed by the type contract. It does not mean the function approves of the animal, performs a successful business action, or returns the input unchanged. Similarly, a Cat-producing function need not obtain its Cat from its argument. It might return a different Cat. The function types in this lab describe input acceptance and output classification only. They do not express identity of values, preservation of runtime species, termination, or any particular algorithm. Keeping these meanings separate prevents extra requirements from entering the replacement test unnoticed.

A function combines a consumer position and a producer position. In Input → Output, the input type describes values the caller may supply, and the output type describes values the caller may receive. Those two permissions point in opposite directions when replacing a function. The replacement cannot demand more specific inputs than the caller promised. It also cannot return results less specific than the caller permits. This is why inspecting only the set of returned values is insufficient. A function with perfectly acceptable results may still reject an input that the required interface authorizes.

A function call has two separate permissions

Caller actionTarget permissionReplacement must support
Send CoraInput is CatAccept every permitted Cat
Receive CoraResult may be CatReturn an Animal
Receive DuneResult may be DogReturn an Animal
Send DuneNot authorized by this targetNo obligation from this target
These examples use the target Cat → Animal. The result need not be the argument or share its subtype.

These descriptions are separate paper interfaces and the finite variance lab, not new constructs in the earlier inference engine. That engine still uses equality for application and has no Animal hierarchy. The present lab chooses source function input and output names from Animal, Cat, and Dog and compares them with a fixed target. It does not execute callbacks or generate functions. Its purpose is to make the input and output obligations inspectable. The shared-cell illustration later supplies a hypothetical counterexample, rather than an unsafe operation that these controls can perform.

A replacement function must accept the promised inputs

Suppose a caller expects Cat -> Animal. A replacement Animal -> Cat is safe: it accepts every promised Cat and returns a permitted Animal subtype. A replacement Dog -> Animal is not safe because the caller can supply a Cat.

For source Sinput -> Soutput to fit target Tinput -> Toutput, require Tinput <: Sinput and Soutput <: Toutput. The caller’s allowed input must fit the replacement’s requirement. The replacement’s output must fit the caller’s expected result. Reading both arrows in the same direction is the common mistake.

Derive the two function obligations independently

The target Cat → Animal is the contract already known to the caller. A source function is the proposed replacement. To use the source safely through that target, every input authorized by Cat must satisfy the source input requirement. If the source accepts Animal, this holds because Cat <: Animal. If it accepts Cat, this holds by equality. If it accepts Dog, it fails because Cat is not Dog. Thus the test starts with the target input and ends with the source input, opposite the overall source-to-target replacement direction.

The output check starts at the source instead. Any result permitted by the source output must fit the target's Animal expectation. Sources returning Cat, Dog, or Animal all pass because each is a subtype of Animal in the stipulated hierarchy. Together the obligations are target input <: source input and source output <: target output. Both must hold. In the fixed-target lab, this yields six passing source signatures out of nine possible pairs. The three failures all have Dog input. The output selector still expresses a genuine obligation even though each offered output satisfies this particular target.

Exactly six of nine source signatures fit

Source inputAnimal outputCat outputDog output
AnimalYesYesYes
CatYesYesYes
DogNo: inputNo: inputNo: input
Rows give source inputs; columns give source outputs. The target is always Cat → Animal. Every offered output passes.

Work through Animal → Cat without compressing the steps. First, the caller may send Cora because the target accepts Cat. The source accepts Cora because it accepts Animal. Second, the source promises a Cat result, so any returned value meets the target's Animal promise. These two facts justify replacement. The source need not return Cora, and it need not receive Dog merely because it could. Its broader input permission is useful precisely because it covers everything the narrower caller interface may request. Nothing requires the caller to exercise all abilities of the supplied function.

Now change only the source input to Dog, keeping Cat output. The output step remains valid, but Cora no longer satisfies the source's input contract. The combined replacement fails before any promised result could help. Changing the output to Animal cannot repair that input mismatch either. This contrast is useful because an appealing result type can distract from an impossible call. A more specific result does not compensate for refusing a permitted input. The obligations are independent conditions joined by "and," not scores that can trade off against each other.

The permissive Animal target output can hide the second kind of failure, so use a separate paper target Cat → Cat. Animal → Animal passes the input obligation but fails the output obligation: a permitted Dog result would violate the Cat promise. Animal → Cat passes both. These paper targets are not extra lab controls. They explain why the output rule is necessary even when the live output choices all fit Animal. A replacement relation must protect every return allowed by the source description, not only a result observed during one convenient execution.

A narrower paper target exposes the output failure

Proposed sourceInput testOutput testFits paper target?
Animal → AnimalCat <: Animal: yesAnimal <: Cat: noNo
Animal → CatCat <: Animal: yesCat <: Cat: yesYes
Dog → CatCat <: Dog: noCat <: Cat: yesNo
This separate paper target is Cat → Cat, not an available target selector. A Dog result witnesses the output failure.

A common mistaken rule would compare source input <: target input, just as it compares outputs. That would wrongly reject Animal → Cat as a replacement for Cat → Animal, even though every target call is accepted. In other settings it would admit a source requiring more than the caller promises. Deriving the rule from a caller's permission avoids memorizing arrows without meaning. Start with a legal target argument, ask whether the source accepts it, then start with a legal source result and ask whether the target accepts it. Each step has a concrete witness when it fails.

Inspect the rules and their result

Check input and output directions separately

ObligationTestPass?
InputCat <: AnimalYes
OutputCat <: AnimalYes

Source Animal → Cat fits Cat → Animal: Yes.

InterfaceSource to targetAllowed?
Read-only producerCat to AnimalYes
Write-only consumerCat to AnimalNo
Read-write cellCat to AnimalNo

Counterexample if a broader writable alias were permitted

Function substitution obligations
Tin<:Sin and Sout<:Tout

S is the supplied function; T is the required function. Input direction reverses; output direction is preserved.

Function presets replace both source components; each selector changes only its named component. The target is always Cat -> Animal. Container presets change only that separate comparison. Animal includes Cat and Dog; no other subtyping relation is assumed. Read-write cells permit both operations through shared aliases.

Counterexample if a broader writable alias were permitted

InterfaceCat instance used as Animal instance?
Read-only producerYes
Write-only consumerNo; reverse direction is safe
Read-write cellNo
Direction follows the operations, not the spelling of a generic type.

Separate function selectors from container comparisons

Start the live lab with Broad input, narrow output. The source becomes Animal → Cat while the target stays Cat → Animal. The input row asks Cat <: Animal and the output row asks Cat <: Animal. Both display Yes, but the identical text comes from different positions. In the first row Cat belongs to the target and Animal to the source. In the second row Cat belongs to the source and Animal to the target. Reading those roles explains why the formula reverses one position even when a particular preset makes the two tests look identical.

Select Too narrow: Dog input. This replaces both source components, giving Dog → Animal. The input row becomes Cat <: Dog and fails; the output row becomes Animal <: Animal and passes. Then select Exact target, giving Cat → Animal. Equality now satisfies both obligations. These presets are complete source replacements. They do not change the target or refine an existing function body. The lab has no execution trace or parser here. The compatibility result follows from the displayed source and target descriptions plus the explicitly declared Animal relation.

Changing one selector isolates an obligation

Source after changeInput obligationOutput obligationFits?
Cat → AnimalCat <: CatAnimal <: AnimalYes
Cat → DogCat <: CatDog <: AnimalYes
Dog → DogCat <: Dog failsDog <: AnimalNo
Animal → DogCat <: AnimalDog <: AnimalYes
The fixed target is Cat → Animal. Changing output to Dog remains acceptable; changing input to Dog does not.

Use the individual selectors to isolate a change. From Exact target, change output to Dog while keeping input Cat. The replacement still fits because Dog <: Animal, although a Cat input and a Dog output differ. This is a direct check that the target does not promise to preserve the input's kind. Next change input to Dog without changing output. The replacement fails solely on the input obligation. Finally choose Animal input to restore compatibility. Following the requirement rows distinguishes a failed call permission from a failed return permission without guessing from an overall status label.

The container presets below are an independent comparison. Cat view to Animal view asks about changing the type parameter from Cat to Animal. The read-only producer passes, the write-only consumer fails, and the read-write cell fails. Animal view to Cat view reverses that parameter change: the consumer passes while the producer and cell fail. Same Cat view makes all three pass by equality. Choosing these presets does not change either function selector. The interface names specify allowed operations; they are not examples of three actual objects whose contents or behavior the lab runs.

For the producer's successful Cat-to-Animal comparison, a read yielding Cora satisfies the broader result requirement. For the consumer's successful Animal-to-Cat comparison, a caller restricted to Cat inputs sends Cora and the broader consumer accepts her. In the reverse consumer direction, Dune gives a counterexample because an Animal-requiring caller may send a Dog. The paired outcomes are therefore not contradictory. They ask different questions about the same type names in different operation positions. A row marked No denies the whole replacement claim, not the existence of any single useful call between those interfaces.

Container presets leave the function comparison unchanged

InterfaceCat supplies AnimalAnimal supplies CatCat supplies Cat
Read-only producerYesNo: may return DogYes
Write-only consumerNo: may receive DogYesYes
Shared read-write cellNo: broader writeNo: narrower readYes
Only the separate parameter comparison changes here. Each row tests every operation allowed by its named interface.

This exploration also reveals a limit of testing only the fixed function target. All its offered outputs are Animals, so changing output alone never exposes a failing output obligation. The earlier paper Cat-output target supplies that missing contrast. Conversely, the container comparison exposes both parameter directions without adding any selector values. Treat these as complementary examples rather than claiming that the controls enumerate all variance situations. The finite lab covers nine source signatures for one target and three named parameter comparisons. The general reasoning comes from permitted calls and results, not from the number of passing checks.

A mutable cell both consumes and produces

A Cell<Cat> can be read as Cat and written with Cat. If it were freely treated as Cell<Animal>, someone could write a Dog through that alias. Reading through the original Cat view would then violate its type. Both directions of safety are needed, so our read-write cell is invariant.

This example is about interfaces with shared read/write access. Rust’s variance rules include lifetime and ownership distinctions and should not be reduced to a slogan that every collection is invariant. TypeScript also makes particular compatibility trade-offs. Use the target language’s documented rules when analyzing real code.

Show why shared reads and writes require both directions

A shared read-write Cell<T> permits both reading a T and writing a T while another alias can refer to the same location. This combines the producer and consumer obligations. To replace Cell<Animal> with Cell<Cat>, reads look safe because Cat results satisfy Animal. Writes fail because the Animal view permits writing Dog. Reversing the replacement makes writes safe but reads unsafe, since an Animal cell might already contain Dog. Invariance names this absence of a general subtype replacement in either direction for the distinct Cat and Animal parameters under these shared operations.

The invalid-alias counterexample is a paper execution, not a command available in the model. Begin with one location storing Cora and an original Cell<Cat> alias. Pretend a forbidden Cell<Animal> alias refers to that same location. Writing Dune through the broader alias satisfies that alias's apparent input rule. Reading through the original alias now yields Dog where Cat was promised. The unsafe step is granting the broader writable alias, not classifying Dune as Animal. Dune really does satisfy Animal. The original Cat guarantee and the newly granted Dog write are incompatible over shared storage.

A forbidden alias would change what the original view reads

Hypothetical forbidden alias: one location L stores Cat, an Animal alias writes Dog, and the original Cat alias reads Dog.One location L, shown at successive times1. Initial CatLocation LCora: Cat2. Broader writeLocation LDune: Dog3. Original readLocation LDune: DogCat view existsAnimal permits DogCat promise failsThe forbidden alias keeps both views usable.Schematic snapshots, not three separate locations
This counterexample assumes both aliases remain usable. The model rejects the substitution rather than permitting the illustrated unsafe write.

The reverse direction needs a different witness. Start with a genuine Cell<Animal> that already stores Dune. Pretending it is Cell<Cat> immediately permits a read expected to yield Cat, but the stored value is Dog. No write through the new alias is needed to expose the failure. This complements the first counterexample: one fails after an allowed broader write, the other fails at the initial narrower read. Seeing both prevents invariance from becoming a rule remembered only for one assignment direction. Both directions must respect all operations that the claimed target interface permits.

Combining the obligations algebraically gives source parameter <: target parameter for reading and target parameter <: source parameter for writing. For Cat and Animal only the first direction holds; for Animal and Cat only the second holds. For Cat and Cat both hold by equality. The lab's base relation has no distinct names that are mutual subtypes, so only equal parameters pass this cell comparison. In other type systems, equivalent descriptions can be written differently. The general criterion is both compatibility directions, not necessarily identical spelling in every possible language or representation.

An interface can change the result by removing an operation or changing alias rules. A read-only view of the same location no longer grants the dangerous Dog write. A write-only view no longer promises to read a Cat. A language with exclusive ownership may also control which old aliases remain usable after a change of view. Ownership means rules about who may access or transfer a value; Rust adds such rules together with lifetime constraints. This lab does not model them. Its shared-access counterexample cannot establish variance for every real mutable collection or reference type.

Each cell direction has its own counterexample

Source cellRequired cellRead obligationWrite obligation
CatAnimalCat <: Animal passesAnimal <: Cat fails
AnimalCatAnimal <: Cat failsCat <: Animal passes
CatCatCat <: Cat passesCat <: Cat passes
One direction fails after a write. The reverse can fail immediately on a read of an already stored Dog.

The transferable question is therefore which substitutions preserve every operation available through the required interface. For a producer, test possible results. For a consumer, test permitted inputs. For a function, test both in their respective directions. For a shared cell, test the interaction across aliases, including later operations that rely on an earlier promise. These arguments establish compatibility only under the declared interfaces and hierarchy. They do not certify a function's business behavior or a language implementation's soundness. They explain precisely which permission would fail, which value witnesses the failure, and which changed interface would remove that particular counterexample.

Exercises

A callback replacement

A slot requires Cat -> Animal. Which fits: Animal -> Dog or Cat -> Cat?

Hint

Check input acceptance and output compatibility independently.

Worked answer
ReplacementAccepts every Cat?Returns Animal?
Animal -> DogYesYes
Cat -> CatYesYes
A function type here does not promise to return its argument.

Both fit in this stipulated hierarchy. The first accepts all Animals, including Cats, and returns a Dog, which is an Animal. The second accepts the promised Cats and returns a Cat, also an Animal. Neither result needs to preserve the input’s exact runtime subtype.

Find the illegal operation

Describe the failure if Cell<Cat> is treated as a writable Cell<Animal>.

Hint

Use the broader view to write a value that is not a Cat.

Worked answer

Counterexample if a broader writable alias were permitted

StepStorageClaimed view
StartCatCell<Cat>
Write through broader aliasDogCell<Animal>
Read original aliasDogPromises Cat
The unsafe write is the counterexample.

Write a Dog through the Animal view, then read through the original Cat view. The same storage now contains a Dog where the original interface promises Cat. A read-only view cannot perform this write, which is why the read-only case has a different variance rule.

Sources and further reading

Selected reference passages support the definitions and algorithms below. The examples, explanations, and implementations are original. Terminology and assumptions are stated within each lesson; source review is not an independent audit of this course. Optional links are not runtime dependencies.