Type Variance

Variance

Variance is the relationship between subtyping, and type compatibility. Understanding variance can help simplify type annotations throughout a codebase, as well as make better (and more concise) use of complex types (likely a future post). You have quite likely already interacted with type variance without knowing it.

Type compatibility

Type compatibility (in a type system) is a concept determining whether a non-exact type can be safely used (as in without breaking type safety) in a given context. For example if a value of type Mammal is expected - should a value of subtype Dog also be allowed? What about a value of supertype Animal? Spoiler alert: it depends!

Type compatibility is a great feature, as it allows code to be type safe, without being overly explicit (or overly restrictive). Take the following example:

    
<?php
 
class Animal {}
 
class Cat extends Animal {}
 
class Dog extends Animal {}
 
// without type variance
function foo(Animal|Cat|Dog $animal): Animal|Cat|Dog
{
//
}
 
// with type variance
function foo(Animal $animal): Animal
{
//
}

Without type compatibility, type annotations would have to be exact. A function accepting “any Animal”, would have to declare its parameter as a union of all possible Animals. This has major drawbacks:
  1. Long type annotations
  2. Declarations would need changing each time a new subclass of Animal is created

Covariance

The simplest type of variance, is covariance. A type annotation is covariant if it is compatible with any of its subtypes. In most types systems, covariance is used for (visible - we'll cover this later) method return types.

    
<?php
 
class Animal {}
 
class Dog extends Animal {}
 
class AnimalHandler {
function adopt(): Animal
{
return new Animal;
}
}
 
class DogHandler extends AnimalHandler {
// method return type being overridden by subtype
function adopt(): Dog
{
return new Dog;
}
}
 
// any usage of AnimalHandler should be
// type safely swappable with DogHandler
$handler = (new AnimalHandler)->adopt();
$handler = (new DogHandler)->adopt();

This means that overriding methods (like DogHandler's adopt method in the above example) are allowed to specify a more precise return type, than the method they are overriding without fear of type errors. This is because any calling code expects the supertype, and any subtype must contain all members of it's supertype. Basically, if they want to “do something” with this Animal - it's definitely also type safe to do on a Dog (as it is a subtype).

This also explains why supertypes are not allowed in overridden method return types. There is no guarantee that a supertype will contain all members of its subtype (in fact they rarely do), and as such - type errors could occur. Consider the following example:

    
<?php
 
class Animal {}
 
class Dog extends Animal {
function bark(): void
{
//
}
}
 
class AnimalHandler {
function adopt(): Dog
{
return new Dog;
}
}
 
class DogHandler extends AnimalHandler {
// INVALID
// method return type being overridden by supertype
function adopt(): Animal
{
return new Animal;
}
}
 
// any usage of AnimalHandler should be
// type safely swappable with DogHandler
(new AnimalHandler)->adopt()->bark();
// type error, as 'Animal' does not have a 'bark' method
(new DogHandler)->adopt()->bark();

An instance of Dog cannot be swapped out with an instance of Animal - as Dog could (and in this case does) contain Dog specific logic not applicable to Animal.

Contravariance

A type annotation is contravariant if it is compatible with any of its supertypes. In most (although not all) type systems, contravariance is used for (visible) method parameters.

    
<?php
 
class Animal {}
 
class Dog extends Animal {}
 
class DogHandler {
function adopt(Dog $animal)
{
//
}
}
 
class AnimalHandler extends DogHandler {
function adopt(Animal $animal)
{
//
}
}
 
$dog = new Dog;
// any usage of AnimalHandler should be
// type safely swappable with DogHandler
(new AnimalHandler)->adopt($dog);
(new DogHandler)->adopt($dog);

This means that overriding methods (like AnimalHandler's adopt method in the above example) are allowed to specify less precise parameter types, than the method they are overriding without fear of type errors. This is because any calling code will pass a Dog. A dog is always a valid Animal (as Dog is a subtype of Animal).

This is likely counterintuitive, as we have widened the types that are allowed into the function. In the above section on covariance, it was explained that this wasn't allowed - what if the subtype adds additional behaviour? This is where the importance of context comes into play. A method has no control over what happens to it's returned value. A method has full control over which of its parameters behaviours it makes use of. By simply not making use of any behaviours not existing inside the supertype, we can guarantee that this code is type safe. It is not possible to make the same guarantee for returned values - as this is out of the control of the method.

The type annotations of an overriding method's parameters cannot be replaced with subtypes (of the overridden method's parameters). In order to be type safe, an overriding method must accept (at least) all values that its overridden counterpart would. Replacing a parameter's types declaration with a narrow type, restricts the allowed values - violating type safety.

    
<?php
 
class Animal {}
 
class Dog extends Animal {}
 
class AnimalHandler {
function adopt(Animal $animal)
{
//
}
}
 
class DogHandler extends AnimalHandler {
// INVALID
// method parameter type being overridden by subtype
function adopt(Dog $animal)
{
//
}
}
 
$animal = new Animal;
// any usage of AnimalHandler should be
// type safely swappable with DogHandler
(new AnimalHandler)->adopt($animal);
// type error: Animal is not a valid Dog
(new DogHandler)->adopt($animal);

Invariance

A type annotation is invariant if it is incompatible with its subtypes, and it's supertypes. In other words, the type provided must be exactly the type expected. In most type systems, invariance is used for (visible) properties.

    
<?php
 
class Animal {}
 
class Dog extends Animal {}
 
class AnimalHandler {
public Animal $thing;
 
public function __construct(Animal $thing)
{
$this->thing = $thing;
}
}
 
class DogHandler extends AnimalHandler {
public Animal $thing;
 
public function __construct(Animal $thing)
{
$this->thing = $thing;
}
}
 
$animal = new Animal;
// any usage of AnimalHandler should be
// type safely swappable with DogHandler
$foo = new AnimalHandler($animal);
$foo = new DogHandler($animal);

This means that overriding properties (like DogHandler's thing property in the above example) are not allowed to specify more or less precise parameter types, than the method they are overriding (as this would violate type safety). This is due to the combination of the justifications for covariance and contravariance. To start with, overriding a property with a supertype would break type safety, as there is no guarantee that the supertype has all of the behaviours of the subtype.

    
<?php
 
class Animal {}
 
class Dog extends Animal {
function bark(): void
{
//
}
}
 
class DogHandler {
public Dog $thing;
 
public function __construct(Dog $thing)
{
$this->thing = $thing;
}
}
 
class AnimalHandler extends DogHandler {
// INVALID
// property being overridden by supertype
public Animal $thing;
 
public function __construct(Animal $thing)
{
$this->thing = $thing;
}
}
 
$animal = new Dog;
// any usage of AnimalHandler should be
// type safely swappable with DogHandler
(new DogHandler($animal))->thing->bark();
// type error: 'Animal' does not have a 'bark' method
(new AnimalHandler($animal))->thing->bark();

Additionally, overriding a property with a subtype would break type safety, as this would rule out previously valid types.

    
<?php
 
class Animal {}
 
class Dog extends Animal {}
 
class AnimalHandler {
public Animal $thing;
 
public function __construct(Animal $thing)
{
$this->thing = $thing;
}
}
 
class DogHandler extends AnimalHandler {
// INVALID
// property being overridden by subtype
public Dog $thing;
 
public function __construct(Dog $thing)
{
$this->thing = $thing;
}
}
 
$animal = new Animal;
// any usage of AnimalHandler should be
// type safely swappable with DogHandler
$foo = new AnimalHandler($animal);
// type error: Animal is not a valid Dog
$foo = new DogHandler($animal);

Context

The contexts in which a value is used determines the variance of its type annotation.

An overriding method has no control over what calling code does with its return value. Thus, it cannot possibly know which behaviours of its overridden counterpart are needed. They could all be needed, and as such it is not suitable to widen the type annotation (as this could lose behaviour). In order to be compatible with its overridden counterpart, it can only be more specific.

An overriding method has full control over what it does with its parameter values. Thus, it can declare a less specific parameter type annotation, and simply choose which shared behaviours to make use of. An overriding method must accept (at least) all values that its overridden counterpart does, and as such cannot narrow the type annotation.

An property has no control over what calling code does with its value, so cannot be overridden with a subtype. A property must accept (at least) all values that its overridden counterpart does, and as such cannot narrow the type annotation.

Visibility

Okay, time to clarify my comments on visibility. Subtypes are only aware of certain supertype members. In most type systems, these are the members annotated as public, or protected.

Private (hidden) members are not bound by the same limits to ensure type safety. For example:

    
<?php
 
class Animal {
private function foo(): string
{
return 'foo';
}
}
 
class Dog extends Animal {
public function foo(): int
{
return 5;
}
}

Dog is still a valid subtype of Animal, and this code is typesafe - despite the methods foo having incompatible return types. This is because Dog's foo method isnt actually overriding Animal's foo method. In fact, Dog doesnt even know Animal has a foo method - because it is private. Private members aren't part of the type contract, they're not relevant for subtyping.