Demystifying Variance
Generic variance controls how subtype relationships between underlying types affect the generic container. If Dog is a subclass of Animal, what is the relationship between Producer<Dog> and Producer<Animal>?
The Core Question of Variance
- If
Dog extends Animal, doesContainer<Dog>extendContainer<Animal>?
The answer depends on whether the container is reading data (Producer), writing data (Consumer), or both (Read-Write):
THE 3 MODES OF GENERIC VARIANCE
1. Invariance (Default) 2. Covariance (Producer) 3. Contravariance (Consumer)
┌─────────────────────────┐ ┌───────────────────────────┐ ┌───────────────────────────┐
│ Exact Match ONLY │ │ Subtypes Allowed (Dog) │ │ Supertypes Allowed │
│ Read-Write Container │ │ Read-Only Container │ │ Write-Only Consumer │
└─────────────────────────┘ └───────────────────────────┘ └───────────────────────────┘1. Invariance (Default / Read-Write Containers)
By default, generics in TypePHP (and PHPStan) are invariant. Invariance requires an exact type match.
/** @template T */
class Box
{
public function __construct(public mixed $item) {}
}
/**
* @param Box<Animal> $box
*/
function checkBox(Box $box): void
{
// ...
}
checkBox(new Box(new Animal())); // Valid
checkBox(new Box(new Dog())); // Invalid in invariant mode!
// Throws: TypeError: Argument $box expects Box<invariant Animal>, but Box<Dog> was givenWhy Invariance is Mandatory for Read-Write Containers
If PHP allowed Box<Dog> to be passed into checkBox(Box<Animal> $box):
function checkBox(Box $box): void
{
$box->item = new Cat(); // Valid for Box<Animal>, but corrupts Box<Dog>!
}Putting a Cat into what the caller thought was a Box<Dog> would corrupt memory state! Invariance completely prevents this bug.
2. Covariance (@template-covariant T / Producer Mindset)
Covariance allows subtypes (Dog for Animal). Think of covariance as a Producer / Read-Only relationship.
If a function only reads from a container producing Animals, passing a container producing Dogs is 100% safe because every Dog read out of the container is guaranteed to be an Animal!
/**
* @template-covariant T
*/
class Producer
{
public function __construct(public mixed $item) {}
}
/**
* Accepts Producer holding Animal or any subtype of Animal (Dog, Cat)
*
* @param Producer<Animal> $producer
*/
function handleProducer(Producer $producer): mixed
{
return $producer->item;
}
// 1. Valid Call (Dog is a subtype of Animal)
handleProducer(new Producer(new Dog()));
// 2. Valid Call (Cat is a subtype of Animal)
handleProducer(new Producer(new Cat()));
// 3. Invalid Call (Car is not an Animal)
handleProducer(new Producer(new Car()));
// Throws: TypeError: handleProducer() expects Producer<covariant Animal>, but Producer<Car> was given3. Contravariance (@template-contravariant T / Consumer Mindset)
Contravariance allows supertypes (Animal for Dog). Think of contravariance as a Consumer / Write-Only relationship.
If a function needs a handler that consumes a Dog, giving it a handler that can consume any general Animal is 100% safe because an Animal handler can process any Dog given to it!
class Puppy extends Dog {}
/**
* @template-contravariant T
*/
class Consumer
{
/**
* @param callable(T): void $handler
*/
public function __construct(public mixed $handler) {}
/**
* @param T $item
*/
public function consume(mixed $item): void
{
($this->handler)($item);
}
}
/**
* Accepts Consumer designed for Dog or any supertype of Dog (Animal)
*
* @param Consumer<Dog> $consumer
*/
function processDogConsumer(Consumer $consumer, Dog $dog): void
{
$consumer->consume($dog);
}
// 1. Valid: Animal handler can safely consume a Dog!
$animalHandler = fn (Animal $a) => null;
processDogConsumer(new Consumer($animalHandler), new Dog());
// 2. Invalid: Puppy handler cannot handle any general Dog!
$puppyHandler = fn (Puppy $p) => null;
processDogConsumer(new Consumer($puppyHandler), new Dog());
// Throws: TypeError: processDogConsumer() expects Consumer<contravariant Dog>, but Consumer<Puppy> was givenInline Usage-Site Variance Syntax
In addition to class-level declarations (@template-covariant T / @template-contravariant T), TypePHP supports declaring variance inline directly on function parameter and return type hints:
/**
* @param Repository<covariant Animal> $repo
* @param Consumer<contravariant Dog> $consumer
* @return Producer<covariant Animal>
*/
function processContracts(Repository $repo, Consumer $consumer): Producer
{
// ...
}Variance Precedence Rules (Usage-Site Overrides)
What happens if an inline type hint specifies Consumer<covariant Animal>, but the class definition declared @template-contravariant T?
TypePHP resolves variance conflicts using Usage-Site Precedence:
- Usage-Site Override: If a function parameter or return type explicitly specifies an inline variance modifier (
covariantorcontravariant), the usage-site modifier takes precedence. - Class-Level Fallback: If the call site uses standard syntax (
Consumer<Animal>), TypePHP falls back to the class's declared@template-covariantor@template-contravariantrule.
/**
* Class declares Contravariant T (Default: Consumer / Supertypes)
*
* @template-contravariant T
*/
class Consumer
{
public function __construct(public mixed $handler) {}
}
/**
* Function parameter EXPLICITLY overrides with inline 'covariant Animal'
*
* @param Consumer<covariant Animal> $consumer
*/
function processCovariantConsumer(Consumer $consumer): mixed
{
return $consumer->handler;
}
// 1. Valid Call (Dog is a subtype of Animal)
// Class declared contravariant, BUT function parameter explicitly specified 'covariant'.
// Usage-site 'covariant' wins!
processCovariantConsumer(new Consumer(new Dog()));
// 2. Invalid Call (Car is not an Animal)
processCovariantConsumer(new Consumer(new Car()));
// Throws: TypeError: processCovariantConsumer() expects Consumer<covariant Animal>, but Consumer<Car> was givenSummary Matrix
| Variance Mode | Keyword / Syntax | Allowed Types | Mental Model |
|---|---|---|---|
| Invariant (Default) | Collection<T> | Exact type only | Read-Write: Prevents container corruption. |
| Covariant | @template-covariant TBox<covariant Animal> | Subtypes (Dog, Cat) | Producer: Safe for reading data out. |
| Contravariant | @template-contravariant TConsumer<contravariant Dog> | Supertypes (Animal) | Consumer: Safe for writing data in. |