Variance
The variance of a template parameter governs whether a subtype substitution at the parameter position produces a subtype of the parameterised type.
| Variance | Rule | Source syntax |
|---|---|---|
| Covariant | $\sigma \mathrel{<:} \sigma'$ implies $\mathrm{C}\langle\sigma\rangle \mathrel{<:} \mathrm{C}\langle\sigma'\rangle$ | @template-covariant T |
| Contravariant | $\sigma \mathrel{<:} \sigma'$ implies $\mathrm{C}\langle\sigma'\rangle \mathrel{<:} \mathrm{C}\langle\sigma\rangle$ | @template-contravariant T |
| Invariant | only $\sigma \equiv \sigma'$ produces $\mathrm{C}\langle\sigma\rangle \equiv \mathrm{C}\langle\sigma'\rangle$ | @template T (default) |
Why variance matters
PHP's type system without variance is rigid: Box<int> $\mathrel{<:}$ Box<mixed> only when the analyser hand-codes the rule. With variance declarations, the lattice answers correctly and uniformly:
- Read-only containers (e.g.
Iterator<T>) are naturally covariant: anIterator<int>is anIterator<mixed>(you can read ints out of it as mixeds). PHP-side:@template-covariant T. - Write-only containers (e.g. a
Sink<T>acceptingTvalues) are naturally contravariant: aSink<mixed>accepts an int (because it accepts everything). SoSink<mixed> $\mathrel{<:}$ Sink<int>. PHP-side:@template-contravariant T. - Read-and-write containers (e.g.
Box<T>with bothget(): Tandset(T): void) are invariant: changingTwould break either the getter or the setter. PHP-side:@template T(default).
How the lattice uses variance
When the lattice decides $\mathrm{C}\langle a_1, \dots, a_n\rangle \mathrel{<:} \mathrm{C}\langle b_1, \dots, b_n\rangle$ (same class on both sides), it asks the world for each parameter's variance, and compares positionally:
- Covariant: $a_i \mathrel{<:} b_i$.
- Contravariant: $b_i \mathrel{<:} a_i$.
- Invariant: $a_i \mathrel{<:} b_i$ AND $b_i \mathrel{<:} a_i$.
If every position passes, the whole refines query passes.
Variance for inherited classes
When the input is a descendant of the container's class, the lattice asks the world to resolve what the descendant supplies to each ancestor parameter. The variance check then runs on the resolved arguments.
For example, with class StringList extends ArrayList<string> and class ArrayList<T> implements Iterator<T>:
StringList <: Iterator<string>
↓
StringList → ArrayList<string> (StringList extends ArrayList<string>)
↓
ArrayList<string> → Iterator<string> (ArrayList implements Iterator<T>, T := string)
↓
Iterator<string> <: Iterator<string> ✓ (covariant or invariant, both pass)
The lattice's object family handles this chain.
Variance and substitution
Variance does not affect substitution itself ; substitution always replaces every template-parameter Element matching the substitution key, regardless of variance. Variance affects the use site: when the analyser asks "is this instance a subtype of that?", the variance is consulted to decide which direction of refines to check.
Default-filled type-args and variance
When a class declares a parameter and the user supplies fewer arguments than declared, the missing args are filled with their declared upper bounds (or mixed if undeclared). The resulting type is flagged as carrying a template default.
The variance check at refinement time consults the flag:
- A default-filled type-arg flowing into a covariant or invariant position is allowed without strict refinement; the lattice records the coercion cause so the analyser can warn.
- A default-filled type-arg flowing into a contravariant position is checked normally.
This rule prevents Box (the parameter-less reference) from being non-refining in Box<int> callsites, while still letting the analyser surface the use of an unpinned parameter in its diagnostics.
A worked example
/**
* @template-covariant T
*/
interface Iterator {
/** @return T */
public function current(): mixed;
}
/**
* @implements Iterator<int>
*/
class IntIterator implements Iterator { /* ... */ }
function f(Iterator $it): void { /* ... */ }
f(new IntIterator());
Checking IntIterator refines Iterator<numeric>:
IntIteratoris not the same class asIterator, so the lattice walks the inheritance.IntIterator implements Iterator<int>; the world resolves position 0 ofIteratorfromIntIteratortoint.- The container's
Iteratordeclares position 0 as covariant. - Covariant: check
int $\mathrel{<:}$ numeric. ✓ (intis in thenumerictrue-union). - Result:
IntIteratorrefinesIterator<numeric>. ✓
If Iterator's T were invariant, the answer would be different: int $\equiv$ numeric is false (numeric admits float and numeric-string, int does not), so the call would fail.
Bivariant / unsafe-covariant
PHP-side, neither is supported. Suffete does not have a fourth variance kind; the standard three are sufficient for what real-world PHP analysers express.
Multiple parameters
When a class has multiple parameters, each has its own variance. Map<K, V> typically declares K as invariant (because keys are used both to read and write) and V as covariant (because the user typically reads values out). The variance check runs per-position with the per-position variance.
flowchart LR
Container["Map<K, V><br/>K invariant, V covariant"] --> Pos0[K: invariant check]
Container --> Pos1[V: covariant check]
Pos0 --> Result[All positions pass → refines]
Pos1 --> Result
See also: refines for the object-family rules that consult variance; specialise for inheritance-binding resolution.