How do covariance and contravariance in method signatures relate to the Liskov Substitution Principle, and what do mainstream languages actually enforce?
answer
- Return covariant, params contravariant, exceptions covariant
- Postcondition ↑ / precondition ↓ in type form
- Java/C#/Kotlin: covariant returns only, params invariant
- equals(MyType) is an overload, not an override
- PECS / out-in; array covariance is unsound
basics
~20 sOverrides may return a more specific type (covariant returns) because promising more is safe, and could in theory accept a more general parameter type (contravariant parameters) because demanding less is safe. Most languages enforce only the return-type rule.
solid answer
~50 sVariance is the type-system encoding of LSP's contract rules. Return types are **covariant**: an override may return a subtype of the declared return type, which is postcondition strengthening — the caller still gets what it expected. Parameter types are theoretically **contravariant**: an override could accept a supertype of the declared parameter, which is precondition weakening — accepting more inputs can't hurt existing callers. Exceptions are covariant: fewer or narrower thrown types only. In practice: Java, C#, Kotlin, and C++ support covariant returns but require **invariant** parameter types — a differing parameter type creates an overload, not an override (Java's classic `equals(Dog)` bug that silently never overrides `equals(Object)`). Few languages implement contravariant parameters. Generic *types* have their own variance (`out T` producers are covariant, `in T` consumers are contravariant; Java uses `? extends`/`? super` wildcards), and the same reasoning explains why arrays being covariant in Java/C# is unsound — it permits a store that must fail at runtime. Variance covers only type-shaped promises; value-level contracts stay unchecked.
code
pseudocode · 17 linesclass Animal; class Dog : Animal
class Base { fun handle(d: Dog): Animal }
class SafeSub : Base {
// return NARROWER -> covariant, promises more -> allowed everywhere
// param WIDER -> contravariant, demands less -> theoretically safe
fun handle(a: Animal): Dog
}
class BadSub : Base {
fun handle(p: Poodle): Animal // param NARROWER: rejects other Dogs -> violation
} // in Java/Kotlin this silently becomes an OVERLOAD
// Unsound array covariance (Java/C#)
val objs: Array<Any> = arrayOfDogs // allowed
objs[0] = Cat() // compiles, fails at runtimego deeper
Know that an override may return a more specific type, and that changing a parameter type usually creates a different method rather than overriding.
State the three directions correctly (covariant returns, contravariant parameters in theory, narrower exceptions) and tie each to the precondition/postcondition rule it encodes.
Contrast theory with what Java/C#/Kotlin actually enforce, explain the silent-overload trap and @Override, cover generic variance with PECS / in-out, and note that variance is necessary but not sufficient for LSP.
Use array covariance as the case study in trading soundness for ergonomics, discuss variance choices when designing library APIs (declaration-site vs use-site variance, TypeScript's deliberate method bivariance), and explain why value-level contracts must be enforced by shared contract tests since no type system reaches them.
## Vocabulary first - **Covariant**: varies *with* the subtyping direction. If `Dog` is a subtype of `Animal`, then position P is covariant when `X<Dog>` (or an override returning `Dog`) is acceptable where `X<Animal>` was expected. - **Contravariant**: varies *against* it. Position P is contravariant when `X<Animal>` is acceptable where `X<Dog>` was expected. - **Invariant**: neither — only the exact type is accepted. - **Override**: a subtype method replacing a supertype method with the same name and compatible signature; calls dispatch dynamically to it. - **Overload**: a *different* method that merely shares a name; selected statically by argument types. Confusing the two is the source of many silent bugs. ## The connection to LSP The Liskov Substitution Principle requires that a subtype not demand more of callers (**preconditions may only weaken**) and not deliver less (**postconditions may only strengthen**). Method-signature variance is exactly what a compiler can mechanically check of that: | Position | Safe direction | LSP rule it encodes | |---|---|---| | Return type | **Covariant** — override may return a narrower type | Postcondition strengthened: caller expecting `Animal` is happy with a `Dog` | | Parameter type | **Contravariant** — override may accept a wider type | Precondition weakened: accepting `Animal` still accepts every `Dog` the caller might pass | | Thrown exceptions | **Covariant** — fewer or narrower | Postcondition strengthened: no new failure modes | Intuition for the parameter case: a machine advertised as "processes `Dog`" can safely be replaced by one that "processes any `Animal`" — anyone bringing a dog is still served. It cannot be replaced by one that only processes `Poodle`, because callers were told any dog would do. A compact way to remember it: **return covariant, parameters contravariant, exceptions covariant.** ## What languages actually enforce - **Covariant return types**: supported by Java (since 5), C#, Kotlin, C++, Scala, TypeScript. Universally the well-known half. - **Contravariant parameter types**: almost nowhere in mainstream OO. Java, C#, Kotlin, and C++ require **invariant** parameter types for an override. Declaring a wider parameter type silently produces an *overload* instead — the classic bug being `boolean equals(MyType other)`, which never overrides `equals(Object)` and so is ignored by collections and hash maps. Java's `@Override` annotation exists largely to catch this. Sather and (partly) Python's structural typing/tooling are among the few environments that model true contravariance. - **Exception narrowing**: enforced in Java for checked exceptions. Unchecked exceptions are unconstrained by the compiler in every mainstream language, so the *behavioural* rule ("no new failure modes") remains a discipline, not a check. - **TypeScript** is deliberately **bivariant** for method parameters (unsound but ergonomic); `strictFunctionTypes` makes standalone function-type parameters contravariant, but method-shorthand declarations stay bivariant for compatibility with common JS patterns. ## Generic type variance — the same rule, one level up For a generic type `Box<T>`, the safe variance depends on how `T` is used: - **Producer → covariant** (`out T` in Kotlin/C#, `? extends T` in Java): only *returns* `T`. `List<Dog>` may be read as `List<out Animal>` because everything it hands you is an `Animal`. - **Consumer → contravariant** (`in T` in Kotlin/C#, `? super T` in Java): only *accepts* `T`. A `Comparator<Animal>` can be used wherever a `Comparator<Dog>` is needed, because it compares any animals including dogs. - **Both → invariant**: a mutable `MutableList<T>` both produces and consumes `T`, so it must be invariant, or you could read a `Cat` out of something you wrote a `Dog` into. The mnemonic is **PECS — Producer Extends, Consumer Super** (Java) or **"out = production, in = consumption"** (Kotlin/C#). It is precisely the same LSP reasoning: a producer only strengthens what it gives, a consumer only weakens what it demands. ## The array covariance counterexample Java and C# make arrays covariant: `Dog[]` is assignable to `Object[]`. This is **unsound** — it permits ``` Object[] objects = new Dog[10]; objects[0] = new Cat(); // compiles; throws ArrayStoreException at runtime ``` Because an array both produces and consumes its element type, it should be invariant. The languages accepted an unsound rule (adding a runtime store check) for pre-generics ergonomics. It's the cleanest evidence that **variance rules are LSP made mechanical, and breaking them relocates the failure to runtime** rather than eliminating it. Generics in both languages are invariant by default precisely to avoid repeating the mistake. ## The hard limit Variance handles only what is expressible in *types*. It cannot express: - "amount must be positive", "the list must be sorted", "the result is never empty" - "this operation is idempotent", "this returns within one millisecond" - "the object is immutable" (the history rule) A subtype can be perfectly variance-legal and grossly LSP-violating — `Square extends Rectangle` has identical signatures throughout. So variance is a *necessary but not sufficient* check; behavioural conformance still needs documentation, assertions, and contract tests run against every implementation. ## Practical takeaways - Use `@Override`/`override` markers everywhere so an accidental overload is a compile error. - Prefer widening a parameter to `Object`/`Any` only when it genuinely means it; a wider parameter with a runtime type check inside is precondition strengthening in disguise. - Apply PECS/`in`/`out` to API generic parameters so callers get maximum flexibility without unsafe casts. - Never rely on array covariance; use generic collections.
- Why do Java and C# require invariant parameter types for overrides instead of allowing contravariance?Mainly overload resolution and JVM/CLR dispatch: a method name plus parameter types identifies the method, so a differing parameter type naturally denotes a different method. Supporting contravariance would complicate overload selection and binary dispatch for a case that is rarely needed. The cost is the silent-overload trap, which `@Override`/`override` markers exist to catch.
- Why is Java's array covariance considered a design mistake?An array both reads and writes its element type, so it should be invariant. Covariance lets you assign a `Dog[]` to an `Object[]` and then store a `Cat`, which the compiler accepts and the runtime must reject with `ArrayStoreException`. It moves a type error to runtime and costs a store check on every write; generics were made invariant by default to avoid repeating it.
- If a subtype's signatures obey all variance rules, is it LSP-compliant?No. Variance is necessary but not sufficient. `Square extends Rectangle` has perfectly variance-legal signatures and still violates substitutability behaviourally. Value-level preconditions, postconditions, invariants, and the history rule are all outside the type system and need contract tests.
A replacement supplier may deliver a better-specified product than contracted (covariant return) and may agree to accept a wider range of raw materials than contracted (contravariant parameter). What it may not do is deliver something less specific than promised, or refuse materials the original contract accepted.
saying these in an interview costs you the question
- Swapping the directions: "parameters covariant, returns contravariant"
- Assuming a wider parameter type in a subclass produces an override — in Java/C#/Kotlin it produces an overload
- Claiming mainstream languages support contravariant parameters
- Thinking `List<Dog>` is usable as `List<Animal>` for a mutable list — it must be invariant
- Believing variance-legal signatures prove behavioural substitutability