rgoussu@goussu: ~/library/applicative-architecture/exercises
~/library/applicative-architecture/exercises cat variance-workout-subject.md

Variance workout — subject

# The self-contained variance work statement — the six drills' rules, code seeds, compile-vs-runtime constraints, and the acceptance checks for each part.

Subjectsaved 2026-08-08 #exercise#variance#covariance#contravariance#solid#liskov#interface-segregation#subject

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:

  1. The runtime version: assign a Cat[] to an Animal[] variable, store a Dog through it, run it, and capture the ArrayStoreException.
  2. 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

  1. Implement mutable Rectangle (setWidth, setHeight, area) and Square extends Rectangle keeping the square invariant (each setter sets both sides).
  2. Write a property test quantified over any Rectangle the code can hand you: after setWidth(w), area() == w * height().
  3. Run it against Square. It must fail — that failure is Liskov substitution violated, caught by a test instead of a code review.
  4. Redesign until the property holds for every member: immutable value types with withWidth(…) returning Rectangle, 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)

  1. Write the fat interface: MultiFunctionDevice with print(Doc), Doc scan(), fax(Doc), and two implementations — a MultiFunctionPrinter using everything and an OldPrinter forced to stub scan/fax.
  2. Segregate: Printer (consumes docs), Scanner (produces docs), Fax (consumes) — OldPrinter now implements only what it honors.
  3. Port the segregated design to Kotlin and annotate: Printer<in D>, Scanner<out D>. Demonstrate both directions with an assignment that only variance allows (a Scanner<ColorDoc> used as Scanner<Doc>; a Printer<Doc> used as Printer<ColorDoc>).
  4. The proof: try interface MultiFunctionDevice<out D> (or in 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

  1. Pick a small interface with real semantics — EmployeeRepository from the Birthday Greetings exercise is ideal, or any repository port: save, findById, findAll.
  2. Write one abstract test class stating the contract in tests: what was saved can be found, unknown ids return empty, findAll reflects every save.
  3. Run it, unchanged, against every implementation: the in-memory fake and a real one.
  4. Add a deliberately broken implementation (e.g. findAll forgets 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:

  1. The array version throws ArrayStoreException at runtime; the List version fails to compile at the assignment; the takeaway sentence names what the compiler now knows.
  2. The property test fails on Square in the naive design and passes for every type in the redesigned one, without weakening the property.
  3. 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.
  4. Kotlin's out/in annotations compile on the role interfaces, both demonstration assignments type-check, and the fat interface's rejection is captured verbatim.
  5. Under strictFunctionTypes: true every error is explained; the surviving method- bivariance case is present and annotated.
  6. 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