Introduction
Assume you're working in a FinTech company, and you're modelling the type of orders you're supposed to handle: a BuyOrder and a SellOrder, both of them extending a common Order interface. For any function that expects an Order as input, passing a BuyOrder is always fine because a BuyOrder is an Order.
Now let's ask a less obvious question: if you have a stream that only ever emits BuyOrder(s), is that stream also usable anywhere a stream of generic Order(s) is expected? If all you ever do is take the next order out of the stream, then yes: a stream of BuyOrder(s) works perfectly wherever a stream of Order is expected, because every single thing it hands you is, undeniably, an Order.
Now, instead of a stream you read from, picture yourself an order processor that you submit orders into, and ask yourself a similar question as before: is a processor for BuyOrder(s) usable into a place where a processor of Order(s) is expected ? Clearly, this time isn't. A processor that only knows how to handle BuyOrder(s) can't stand in wherever a generic Order processor is needed, since someone could hand it a SellOrder and it would have no idea what to do with it. But a processor built to handle any Order can always stand in wherever a BuyOrder-only processor is expected, because handling every kind of order already includes handling buy orders.
That question, whether a generic type inherits the subtyping relationship of what it wraps, and whether the answer flips depending on whether you're reading from it or writing into it, is called variance. A read-only stream is covariant: it follows the same direction as the subtype relationship (BuyOrder to Order, Stream[BuyOrder] to Stream[Order]). A write-only processor runs the opposite direction, it's contravariant. Every mainstream language has to answer this somewhere. What differs, and what this post is actually about, is where each language makes you answer it, and whether it checks that your answer is safe.
Python: a hint that needs external validation
Python's typing module lets you mark a TypeVar as covariant or contravariant, but like the rest of typing, it is purely a hint for an external checker such as mypy or pyright. Nothing about it runs at runtime, and in practice the overwhelming majority of generic Python classes use a plain, unmarked TypeVar and never raise the question at all.
from typing import TypeVar, Generic
T_co = TypeVar("T_co", covariant=True)
class OrderStream(Generic[T_co]):
def __init__(self, order: T_co) -> None:
self._order = order
def next(self) -> T_co:
return self._order
# A type checker that knows BuyOrder is a subtype of Order will accept
# this, purely because T_co was explicitly marked covariant above:
buy_stream: OrderStream["BuyOrder"] = OrderStream(BuyOrder(qty=100))
order_stream: OrderStream["Order"] = buy_stream # fine, per the covariant flag
# Nothing enforces that the flag was set correctly, or set at all.
# Most stream-like classes in real Python code use a plain TypeVar
# with no variance marker.Marking a TypeVar contravariant instead lets a checker approve the OrderProcessor substitution from the introduction, the same optional, unenforced way:
T_contra = TypeVar("T_contra", contravariant=True)
class OrderProcessor(Generic[T_contra]):
def process(self, order: T_contra) -> None:
print(f"processing {order}")
# A type checker that knows BuyOrder is a subtype of Order will accept
# this, purely because T_contra was explicitly marked contravariant:
generic_processor: OrderProcessor["Order"] = OrderProcessor()
buy_processor: OrderProcessor["BuyOrder"] = generic_processor # fine, per the contravariant flag
# As before, nothing stops you from skipping the flag entirely, or from
# getting the direction backwards; the checker just trusts what you wrote.TypeScript: unsound by design
TypeScript has no syntax for declaring variance. Because its type system compares types structurally rather than by an explicit annotation, variance falls out implicitly from those comparisons, and for mutable containers like arrays, the default it lands on is unsound. Arrays are treated as covariant unconditionally, even when they're also writable.
interface Order { id: string; quantity: number; }
interface BuyOrder extends Order { maxPrice: number; }
const buyOrders: BuyOrder[] = [{ id: "b1", quantity: 100, maxPrice: 50.25 }];
// TypeScript allows this: BuyOrder[] is treated as a subtype of Order[].
const orders: Order[] = buyOrders;
// This compiles with no error, because orders is typed as Order[].
orders.push({ id: "s1", quantity: 50 } as Order); // actually a SellOrder in disguise
// But orders and buyOrders are the SAME array in memory. This now blows
// up at runtime, since buyOrders[1] has no "maxPrice":
console.log(buyOrders[1].maxPrice.toFixed(2)); // TypeError: Cannot read properties of undefinedNo cast was required to trigger this and no compiler flag would have caught it: TypeScript's array covariance is unsound by design, a known and accepted gap in the type system, not an edge case you opted into.
Contravariance fares better, at least for plain function types. Unlike the array case above, TypeScript checks this substitution correctly:
type Processor<T> = (order: T) => void;
const genericProcessor: Processor<Order> = (order) => console.log(order.id);
// Correctly accepted: a function that can handle ANY Order can be used
// wherever a function that only needs to handle BuyOrder is expected.
const buyProcessor: Processor<BuyOrder> = genericProcessor;
const buyOnlyProcessor: Processor<BuyOrder> = (order) => console.log(order.maxPrice);
// Correctly rejected: a function that only knows how to handle BuyOrder
// cannot stand in for one that must be able to handle any Order.
// const broken: Processor<Order> = buyOnlyProcessor;The inconsistency is the real lesson here: TypeScript's structural variance isn't uniformly wrong, it's unaudited, sound for a shape like this one, unsound for a mutable array, with no annotation anywhere telling you which one you're getting in a given case.
Java: the right answer, rewritten every time
Java gets the stream-versus-processor distinction right, but it makes you re-declare it at every single call site. Generic types are invariant by default: List<BuyOrder> is simply not a List<Order>, and no relationship exists between them out of the box. To recover covariant or contravariant behavior, you have to use wildcards: ? extends T for a read-only stream, ? super T for a write-only processor (the PECS mnemonic: Producer Extends, Consumer Super). Java has no way to bake that decision into the type itself, so it must be made explicit at every method signature that needs it.
class OrderStream<T extends Order> {
// ok, a stream with only one element is silly, but that's just an example
private final T order;
public OrderStream(T order) { this.order = order; }
public T next() { return order; }
}
// OrderStream<T> itself carries no variance. Every caller that wants
// covariant, read-only behavior has to spell it out, right here:
static double totalNotional(List<? extends Order> orders) {
double total = 0;
for (Order o : orders) total += o.getQuantity() * o.getPrice();
return total;
}
// And every caller that wants contravariant, write-only behavior
// spells out a DIFFERENT wildcard, case by case:
static void routeSellOrders(List<? super SellOrder> sink) {
sink.add(new SellOrder("s2", 75));
}
// Forget the wildcard, and this simply doesn't compile:
List<BuyOrder> buyOrders = new ArrayList<>();
// totalNotional(buyOrders); // error without "? extends Order" aboveJava is sound and typesafe, unlike the TypeScript and Python examples above. The price is ceremony. ? extends and ? super are not properties of List. They describe how this particular parameter intends to use it, restated in every method that uses it.
And that ceremony is not a quirk of List. It applies the same way to a custom generic type shaped like the processor from the introduction:
class OrderProcessor<T extends Order> {
void process(T order) { System.out.println("processing " + order); }
}
// Just like OrderStream<T>, OrderProcessor<T> carries no variance of its
// own, the bound above only restricts it to Order-shaped types. A method
// that wants "anything able to process at least a BuyOrder" still has to
// spell out the wildcard here too:
static void handle(OrderProcessor<? super BuyOrder> processor, BuyOrder order) {
processor.process(order);
}
OrderProcessor<Order> genericProcessor = new OrderProcessor<>();
handle(genericProcessor, new BuyOrder("b3", 10)); // fine: Order is a supertype of BuyOrder
OrderProcessor<SellOrder> sellOnlyProcessor = new OrderProcessor<>();
// handle(sellOnlyProcessor, new BuyOrder("b4", 5)); // error: SellOrder isn't "? super BuyOrder"Scala: answer it once, at the type
Scala lets a generic type declare its own variance once, at its definition: +A for covariant (a stream), -A for contravariant (a processor), and a plain A for invariant, which is Java's and TypeScript's unmarked default. Everywhere that type is used afterwards, the compiler already knows the answer.
trait OrderStream[+A]:
def next: A
val buyStream: OrderStream[BuyOrder] = new OrderStream[BuyOrder] { def next = BuyOrder("b1", 100) }
// No wildcard, no annotation at the call site: OrderStream was declared
// covariant once, so this assignment just works.
val orderStream: OrderStream[Order] = buyStream
trait OrderProcessor[-A]:
def process(order: A): Unit
val genericProcessor: OrderProcessor[Order] = (o: Order) => println(s"routing ${o.id}")
// Contravariance, symmetrically: a processor that can handle any Order
// can obviously handle a SellOrder specifically.
val sellProcessor: OrderProcessor[SellOrder] = genericProcessorMore to the point, the compiler does not take +A on faith. It checks the declaration is safe by looking at every position A appears in throughout the trait's body. A covariant type parameter may not appear in a position that something can be written into, which is exactly the "stream that is secretly also a processor" problem from the introduction:
trait OrderStream[+A]:
def next: A
def submit(order: A): Unit
// error: covariant type A occurs in contravariant position
// in type A of value submitThis is exactly the mistake TypeScript's array let through silently, and it's why Scala won't even let you write OrderStream[+A] with a mutating submit method, let alone compile a program that misuses it. It's also the same tension behind why the immutable collections covered a few posts back default to covariant, persistent structures in the first place: a container that's both freely readable as a supertype and freely writable is not a safe combination, and Scala's compiler made the defensive choice to enforce covariance, instead of leaving it as a gap.
Recap: where each approach lands
Python mostly skips the subject: an unmarked TypeVar is the common case. TypeScript answers it implicitly, and for mutable arrays incorrectly, trading soundness for a convenience nobody asked for. Java supports variance properly, then asks you to re-derive and rewrite the answer at every call site that cares, with ? extends and ? super as permanent, easily confused ceremony. Scala is the only one of the four where the answer lives in exactly one place, the type definition, and the compiler holds you to it everywhere that type is used.
