Variance markers tell the compiler how subtyping flows through a
generic parameter. out T declares the parameter covariant; in T
declares it contravariant; unmarked is invariant. With markers in
place, specializations get real extends chains and PHP's native
LSP carries the subtype relationship.
<?php
declare(strict_types=1);
namespace App;
// Covariant: T appears in return positions (and in a private property).
// A *private* `T` property is variance-exempt — PHP doesn't type-check private
// slots across the `extends` chain — so the backing field keeps its real type
// (a public/protected `T` property would be rejected; see the rules below).
class Producer<out T> {
public function __construct(private T $item) {}
public function get(): T { return $this->item; }
}
// Contravariant: T appears in parameter positions only
class Consumer<in T> {
public function accept(T $x): void { /* ... */ }
}
// Unmarked T is invariant -- no subtype edge between specializations
class Box<T> {
public function __construct(public T $item) {}
public function set(T $item): void { $this->item = $item; }
}
class Fruit {}
class Banana extends Fruit {}
// With out T, this is now a valid downcast at the type-system level:
function eat(Producer<Fruit> $p): Fruit {
return $p->get();
}
eat(new Producer::<Banana>(new Banana()));For each subtype relationship Banana extends Fruit in your source,
the compiler emits a real extends edge between the corresponding
specializations:
namespace XPHP\Generated\App\Producer;
class T_<hash-of-fruit> implements \App\Producer {
public function __construct(private \App\Fruit $item) { ... } // real type, runtime-checked
public function get(): \App\Fruit { ... }
}
class T_<hash-of-banana> extends T_<hash-of-fruit> implements \App\Producer {
public function __construct(private \App\Banana $item) { ... } // narrowed; `__construct` is LSP-exempt and the private slot isn't checked across the edge
public function get(): \App\Banana { ... }
}PHP's native LSP handles the relationship from there — passing a
Producer<Banana> where a Producer<Fruit> is required Just Works,
including reflection and instanceof.
For contravariant in T, the edge flips: Consumer<Fruit> extends Consumer<Banana>.
A covariant slot can hold another generic as its argument, and the edge
composes through it — including when the inner argument is a generic of a
different but related template. A covariant Tuple<out A, out B> holding a
covariant container relates by its element relationship:
interface Collection<out E> { /* … */ }
class ImmutableList<out E> implements Collection<E> { /* … */ }
interface Tuple<out A, out B> { /* … */ }
class Couple<out A, out B> implements Tuple<A, B> { /* … */ }
// Book <: Product, ImmutableList implements Collection, all covariant ⇒
// Tuple<ImmutableList<Book>, Tag> ⊑ Tuple<Collection<Product>, Tag>The compiler proves the argument relationship ImmutableList<Book> ⊑ Collection<Product> by threading the subtype's element up its
implements/extends chain to the supertype's template
(ImmutableList<Book> → Collection<Book>) and comparing under the inner
template's variance (Book ⊑ Product under Collection's covariant E),
then emits the Tuple edge. So a Couple<ImmutableList<Book>, Tag> is
usable where a Tuple<Collection<Product>, Tag> is required, with the
covariance honoured at runtime (instanceof, type hints), not only at
check. The edge is emitted only when the relationship is positively
provable — an unrelated or unprovable inner pair stays conservative (no
edge), never a bogus one that would PHP-fatal at autoload.
An extends edge only emits when the compiler can prove the element relationship
from the source set. If an element type is not in the compiled .xphp source set and
isn't a recognized PHP built-in — typically a plain-.php domain class — the compiler
can't prove it, so the edge is silently skipped. The specializations still work in
isolation, but they aren't linked: passing a Producer<Banana> where a
Producer<Fruit> is expected then fails at runtime with a TypeError, because the
covariant edge never formed.
xphp check reports this as a non-failing warning at the instantiation site
(xphp.variance_edge_unprovable), naming the type and pointing at "add it to the source
set." It mirrors the bounds "cannot prove satisfaction" check for the same condition (see
type bounds); the fix is the same — include the element type in the
source set the compiler builds its hierarchy from, so the edge can be proven and emitted.
Position rules, enforced at compile time over the collected definitions
(Registry::validateVariancePositions):
| Position | out T allowed? |
in T allowed? |
|---|---|---|
| Method return type | ✅ | ❌ |
| Method parameter | ❌ | ✅ |
By-reference parameter (T &$x) |
❌ | ❌ |
| Constructor parameter (plain) | ✅ | ✅ |
| Private property (mutable/readonly) | ✅ | ✅ |
| Private promoted constructor property | ✅ | ✅ |
| Public/protected property | ❌ | ❌ |
| Public/protected promoted property | ❌ | ❌ |
| Bound expression | ❌ | ❌ |
| Default expression | ❌ | ❌ |
The strict-invariance rule on public/protected properties (mutable,
readonly, and promoted-constructor) is forced by the runtime model: xphp emits
real extends chains between specialised classes, and PHP enforces invariant
property types across those chains for visible members regardless of readonly
— a covariant one would PHP-fatal at autoload when the variance edge lands.
A private property (declared or promoted, mutable or readonly) is the
exception: PHP does not type-check private property types across an extends
chain — a private slot is per-declaring-scope and is never inherited — so each
specialisation keeps its own real-typed field (private Banana $item /
private Fruit $item) with no fatal. A private member is also invisible to the
externally-visible variance surface, so it may carry any variance soundly, and
xphp emits it with its real substituted type. (Detection is by the private
visibility bit: a readonly-only promoted parameter is implicitly public, and an
asymmetric public private(set) property is externally readable, so both stay
strictly invariant.)
A by-reference parameter (function f(T &$x)) is likewise invariant: the
caller's variable is both read and written back through the reference, so it acts
as input and output — neither out T nor in T is sound. This holds in method,
constructor, and nested closure/arrow signatures.
A plain (non-promoted) constructor parameter is the exception: it may
carry out T / in T at any variance, and xphp emits it with its real
substituted type. A constructor parameter isn't part of the externally-visible
variance surface (a constructor is never reached through an upcast reference —
the same reason Kotlin exempts constructor parameters from variance checks), and
PHP exempts __construct from LSP signature checks, so the specialisations'
constructors may legitimately differ across the edge. That's what lets a
covariant immutable collection take type-checked construction input (see
below). A promoted constructor parameter is a property, so it follows the
property rules above: a public/protected one stays strictly invariant, while a
private one is exempt and keeps its real type — which is exactly what makes
the covariant single-value Producer<out T> shape at the top of this page work.
A covariant container can take its element type in its constructor — the
backbone of a read-only List<out T>-style collection:
class ImmutableList<out T> {
private array $items;
public function __construct(T ...$items) { $this->items = $items; }
public function get(int $i): T { return $this->items[$i]; }
}
function firstProduct(ImmutableList<Product> $items): Product { return $items->get(0); }
// Covariance: an ImmutableList<Book> is accepted where ImmutableList<Product>
// is expected, because Book extends Product.
$books = new ImmutableList::<Book>(new Book(), new Book());
$p = firstProduct($books);The constructor parameter keeps its real element type on every specialisation
(Book ...$items on ImmutableList<Book>, Product ...$items on
ImmutableList<Product>), and ImmutableList<Book> still extends ImmutableList<Product> without a PHP fatal — PHP doesn't signature-check
__construct across the chain. (A variant class cannot be declared final:
its specializations are linked by extends edges, which a final class can't
anchor, so final on a out T/in T class is rejected at compile time.)
✅ Construction is runtime-type-checked. Because the constructor parameter keeps its real type, PHP enforces it at construction: building an
ImmutableList<Book>from a non-Bookthrows aTypeError. You get both covariance and a real construction-time guarantee — nothing is erased. The one property shape that can't carry a realTis a non-private (public or protected) stored property — PHP enforces invariant property types across the edge for visible members. A private stored property can hold a realT(see the single-valueProducer<out T>at the top), so a covariant single-value container needs nomixedbacking at all. A multi-element collection likeImmutableListis different: many elements live in oneprivate array $itemsfield, which xphp emits without a value-type annotation — so it compiles and runs fine but trips the optionalxphp checkPHPStan pass at level 6+ (the untypedarrayproperty has no iterable value type; see caveats).
When a generic class uses another generic class in its method signatures, the bounds compose:
// Container's X is invariant, so Container<T> reads as invariant
// regardless of T's outer variance.
class P<out T> {
public function f(): Container<T> {} // REJECTED
}T appears in a covariant outer position (return), but the inner
Container<X> has X as invariant — the composed position is
invariant, so the outer out T is rejected. The validator walks every
generic class's method signatures, bounds, and defaults to apply this
composition.
Composition can also permit a position that looks wrong at a glance. A consuming method that takes a contravariant generic is sound on a covariant class:
interface Comparator<in T> { public function compare(T $a, T $b): int; }
class Box<out E> {
public function pick(Comparator<E> $c): ?E { /* … */ } // ALLOWED
}E is in a contravariant slot (Comparator<in T>) inside a contravariant
parameter position — contra ∘ contra = covariant, which a covariant
out E may occupy. (Under an upcast, a Box<Book> viewed as Box<Product>
takes a Comparator<Product>, which by contravariance compares the Book
elements — sound.) This is the element-consuming counterpart to the
covariant immutable constructor: a mixed-free, fluent
sortedWith/minWith/pick on a covariant collection. A direct
covariant E in a parameter (pick(E $x)) stays rejected — only the
composed position is covariant here, not the bare one.
-
⚠️ Not allowed on closures or arrows — anonymous templates don't have stable identity for anextendschain. See caveats. -
⚠️ Trait-imported methods aren't walked for variance — a generic class thatuses a trait whose method references T won't catch variance violations inside that trait method. Niche; see caveats.
- Test fixture:
test/fixture/compile/variance_covariant_happy/ - Test fixture:
test/fixture/compile/variance_contravariant_happy/ - Test fixture:
test/fixture/compile/variance_with_defaults_and_bounds/ - Related: type bounds, runtime semantics