Brief
You keep meeting variance in the wild — a wildcard you copy from Stack Overflow, a
Kotlin out you cargo-cult, a TypeScript error that vanishes when you switch an arrow
function to a method — without ever being made to predict what the compiler will say.
This workout fixes that. Six short drills, three languages, one rule: for every case you
write, say out loud whether it compiles before you find out. By the end, "producers
covariant, consumers contravariant, read-write invariant" should feel less like a slogan
and more like the obvious shape of data flow.
Instructions
Part 1 — Break arrays (Java)
Write, in one scratch file each:
- The runtime version: assign a
Cat[]to anAnimal[]variable, store aDogthrough it, run it, and capture theArrayStoreException. - The generic version: the same three lines with
List<Cat>/List<Animal>— the assignment itself must be the compile error.
End the commit message or a README line with one sentence: where did the check move, and what did the compiler need to know to move it?
Part 2 — Rectangle/Square under property tests
- Implement mutable
Rectangle(setWidth,setHeight,area) andSquare extends Rectanglekeeping the square invariant (each setter sets both sides). - Write a property test quantified over any
Rectanglethe code can hand you: aftersetWidth(w),area() == w * height(). - Run it against
Square. It must fail — that failure is Liskov substitution violated, caught by a test instead of a code review. - Redesign until the property holds for every member: immutable value types with
withWidth(…)returningRectangle, or two unrelated types behind a common read-only interface. Deleting the inheritance is allowed — that's a finding, not a defeat.
Part 3 — PECS drill (Java)
Implement, signatures first, bodies after:
static <T> void copy(List<? extends T> src, List<? super T> dst)static <T> T max(Collection<? extends T> items, Comparator<? super T> cmp)static <T> void addAll(Collection<? super T> target, T... elements)
Then build the harness: a set of call sites that must compile
(copy(List<Cat>, List<Animal>), a Comparator<Animal> ordering a List<Cat>…) and a
set that must not (copy(List<Animal>, List<Cat>), max over unrelated types…).
Keep the must-not-compile cases as commented-out lines, each with the compiler's actual
complaint pasted above it.
Part 4 — The ISP→variance kata (Java → Kotlin)
- Write the fat interface:
MultiFunctionDevicewithprint(Doc),Doc scan(),fax(Doc), and two implementations — aMultiFunctionPrinterusing everything and anOldPrinterforced to stubscan/fax. - Segregate:
Printer(consumes docs),Scanner(produces docs),Fax(consumes) —OldPrinternow implements only what it honors. - Port the segregated design to Kotlin and annotate:
Printer<in D>,Scanner<out D>. Demonstrate both directions with an assignment that only variance allows (aScanner<ColorDoc>used asScanner<Doc>; aPrinter<Doc>used asPrinter<ColorDoc>). - The proof: try
interface MultiFunctionDevice<out D>(orin D) on the fat Kotlin interface. Keep the compiler error in a comment — the fat interface is invariant by necessity, and the segregation is what unlocked the annotations.
Part 5 — strictFunctionTypes koans (TypeScript)
In one file, with strictFunctionTypes: false, write ~10 assignability assertions
between function types ((a: Animal) => void vs (c: Cat) => void, both directions,
plus return-type cases and an interface with a method versus a property holding an
arrow function). Predict each in a comment. Flip the flag to true, then:
- explain every new error in one line each (which parameter position broke, and why contravariance forbids it);
- keep one method case that still compiles under the flag — the documented bivariance loophole — and note why the flag spares methods.
Part 6 — LSP as contract tests
- Pick a small interface with real semantics —
EmployeeRepositoryfrom the Birthday Greetings exercise is ideal, or any repository port:save,findById,findAll. - Write one abstract test class stating the contract in tests: what was saved can
be found, unknown ids return empty,
findAllreflects every save. - Run it, unchanged, against every implementation: the in-memory fake and a real one.
- Add a deliberately broken implementation (e.g.
findAllforgets the last insert). It must fail the same suite — if it passes, the contract is too weak; strengthen it.
Examples
Part 1, the two versions side by side:
Animal[] pen = new Cat[1];
pen[0] = new Dog(); // compiles; ArrayStoreException at runtime
List<Animal> list = new ArrayList<Cat>(); // does not compile
Part 4, the variance the segregation buys (Kotlin):
interface Scanner<out D> { fun scan(): D }
interface Printer<in D> { fun print(doc: D) }
val s: Scanner<Doc> = colorScanner // Scanner<ColorDoc> — ok, produces
val p: Printer<ColorDoc> = plainPrinter // Printer<Doc> — ok, consumes
Part 5, one koan:
declare let handleAnimal: (a: Animal) => void;
declare let handleCat: (c: Cat) => void;
handleCat = handleAnimal; // ok — Animal handler accepts any Cat
handleAnimal = handleCat; // error under strictFunctionTypes — predict it first
Constraints
- Predict before you run. Every case gets a one-line prediction comment written before the first compile; wrong predictions stay in the file, corrected below — they are the learning record.
- Must-not-compile cases stay in the repo as commented-out code with the actual compiler message; a case silently deleted teaches nothing.
- One part per commit, each with the one-sentence takeaway in its message.
- Parts 1, 3 in Java; part 4 ends in Kotlin; part 5 in TypeScript; parts 2 and 6 in whichever of those you like. No other tooling required than the three compilers and a test runner.
Acceptance
Mapped to the exercise's milestones:
- The array version throws
ArrayStoreExceptionat runtime; theListversion fails to compile at the assignment; the takeaway sentence names what the compiler now knows. - The property test fails on
Squarein the naive design and passes for every type in the redesigned one, without weakening the property. - All must-compile call sites compile; every must-not-compile line carries the real compiler complaint; no signature uses a raw type or an unchecked cast.
- Kotlin's
out/inannotations compile on the role interfaces, both demonstration assignments type-check, and the fat interface's rejection is captured verbatim. - Under
strictFunctionTypes: trueevery error is explained; the surviving method- bivariance case is present and annotated. - One abstract suite runs green against the fake and the real implementation, and red against the sabotaged one — with no per-implementation test code beyond construction.
Related
- Variance workout — LSP & ISP through the type checker — the exercise this is the subject of.
- Java generics tutorial — wildcards,
Kotlin generics,
TypeScript
strictFunctionTypes— the language references the drills lean on.