xphp accepts a closure signature type — Closure(int $x, string $y): bool — anywhere a type hint is allowed (a parameter, a return, a
property). It documents the shape of the callable a slot expects, then
erases to a bare \Closure at compile time, so the emitted PHP is
ordinary code that any PHP runtime accepts.
<?php
declare(strict_types=1);
namespace App;
// A factory that returns an int-to-int closure.
function adder(int $by): Closure(int $x): int {
return fn(int $x): int => $x + $by;
}compiles to:
<?php
declare(strict_types=1);
namespace App;
function adder(int $by): \Closure {
return fn(int $x): int => $x + $by;
}The parameter names inside the signature are documentation only (exactly like a real closure's parameter names); only the types, order, by-reference markers, and arity carry meaning.
A signature may be nullable (?Closure(int): int), may omit the return
(Closure(int $x) — any return accepted), and may nest
(Closure(Closure(int): int $f): int).
Where a closure literal is returned against a Closure(...) return
type, xphp checks that the literal actually conforms, using the same
variance PHP enforces when an inherited method overrides its prototype:
- Parameters are contravariant — each parameter of the literal must be the same as or wider than the target's.
- The return is covariant — the literal's return must be the same as or narrower than the target's.
- By-reference-ness is exact, and arity must be compatible (the literal must accept every argument the target guarantees, and require no more than the target guarantees).
function makeAdder(): Closure(int $x): int {
return fn(int $x): int => $x + 1; // ✓ conforms
}
function makeBroken(): Closure(int $x): int {
return fn(string $x): int => 0; // ✗ compile error:
// parameter 1 is not wider than int
}A mismatch is a compile error (xphp compile fails; xphp check reports
xphp.closure_conformance). See errors.
Scalars can't show why the rules point the way they do — int and string
are unrelated, so neither is "wider" than the other. A class hierarchy makes it
visible. Take:
class LivingThing {}
class Animal extends LivingThing {}
class Dog extends Animal {}
class Cat extends Animal {}and a slot that wants a function taking a Dog and returning some Animal:
function pipeline(): Closure(Dog $d): Animal {
return /* one of the literals in the table below */;
}The insight is that the slot owns both ends of the call: it will only ever
hand the closure a Dog, and it promises its own caller an Animal
back.
- Parameters are contravariant — a wider parameter is safe. The closure is
only ever called with a
Dog, so a closure that accepts anyAnimal(or even anyLivingThing) copes fine — aDogis anAnimal. A closure that demands a narrower or sibling type can't: it would be handed aDogit refuses. - The return is covariant — a narrower return is safe. The caller only
relies on getting some
Animalback, so a closure returning aDog(a kind ofAnimal) satisfies it. One returning a mereLivingThingdoesn't — the caller could get aLivingThingthat isn't anAnimal.
| Returned literal | Parameter | Return | Verdict |
|---|---|---|---|
fn(Dog $d): Animal |
exact | exact | ✓ accepted |
fn(Animal $a): Animal |
wider (contravariant) | exact | ✓ accepted |
fn(Dog $d): Dog |
exact | narrower (covariant) | ✓ accepted |
fn(Animal $a): Dog |
wider | narrower | ✓ accepted |
fn(Cat $c): Animal |
sibling — not wider | — | ✗ parameter 1: Cat is not wider than Dog |
fn(Dog $d): LivingThing |
exact | wider — not narrower | ✗ return type: LivingThing is not a subtype of Animal |
(The real diagnostics name the classes fully-qualified; short names are used here for readability.)
If your instinct says "if I expect a Dog, I shouldn't have to handle a
Cat" — that's exactly right, and it's why fn(Cat $c) is rejected: the slot
will pass a Dog, and a Cat-handler can't take it. The very same instinct is
what makes the wider fn(Animal $a) safe, not unsafe: a handler for any
Animal never chokes on the Dog it's given. Contravariance widens what's
accepted on the way in; covariance narrows what's promised on the way out.
This is the same variance PHP already enforces when a subclass overrides a method — a wider parameter and a narrower return are exactly what PHP permits in an override. It's a structural, per-signature check, and it is distinct from the declaration-site
out T/in Tclass variance in Variance, which instead wires realextendsedges between whole specializations. Same underlying principle — subtyping flows one way through inputs and the other through outputs — different mechanism.
The check is deliberately one-directional: it never rejects code it cannot
prove wrong. A parameter or return that is untyped (⇒ mixed), a class the
source set doesn't declare, a still-abstract generic type parameter, a
self/static/parent/object/iterable/callable leaf, or a nullable /
union / intersection type is accepted. A built-in supertype (a
Closure(): \Throwable target) is accepted whenever the candidate could
genuinely satisfy it — a subclass of a built-in (\Exception), an enum against
an interface it implements, a __toString class against \Stringable, or any
class with an unknown ancestor. Only a candidate whose entire declared
ancestry is user code with no built-in anywhere is provably unrelated to a
built-in target, and only that is rejected. This mirrors the RFC's runtime
leniency — lenient while unresolved, decide only when provable.
A closure literal is checked wherever it statically meets a Closure(...)
target — both the return-position "factory" pattern above and a literal
passed as a call argument to a Closure(...)-typed parameter, for every
statically-resolvable callee: a generic instance / static / free-function
turbofish call, and a plain (non-turbofish) call to a non-generic method or free
function whose receiver/name resolves.
function each<T>(Closure(T $x): string $fn, T $seed): string { return $fn($seed); }
each::<int>(fn(int $x): string => (string) $x, 1); // conforms
each::<int>(fn(string $x): string => $x, 1); // rejected: string is not wider than intOnly a callee the compiler cannot statically resolve keeps a call argument
gradual (accepted): a callable held in a variable ($fn(...)), a built-in
higher-order function (array_map(...)), or any other dynamic callee — the
compiler cannot even see that the parameter is a Closure(...). A default value
is never a closure (PHP requires a constant expression), so it is not a site.
When the callee sits inside a still-abstract generic template and its
Closure(...) parameter references the enclosing type parameter, the argument is
gradual at the template and rechecked once the template specializes — a
$this->m(...) / self::m(...) self-call to a method of the same class is
rejected or accepted per the concrete type argument, exactly like the
return-position grounding. Two boundaries remain gradual here: a self-call to an
inherited method, and a call through a non-$this receiver of the same
generic type (e.g. $other->m(...) where $other is the enclosing Box<E>).
A closure signature may reference the enclosing type parameters, and each is grounded against the concrete type argument when the class specializes:
class Registry<T> {
public function factory(): Closure(T $value): bool {
return fn(int $value): bool => $value > 0;
}
}
new Registry::<int>(); // target grounds to Closure(int): bool — the literal conforms
new Registry::<string>(); // target grounds to Closure(string): bool — the same literal
// is now rejected: int is not wider than stringA flat union A|B, intersection A&B, or nullable ?A inside a signature is
variance-checked member by member, following PHP's own subtyping:
- a union in a return conforms when the literal's return fits some member
(
Closure(): int|stringaccepts afn(): int); - a union in a parameter requires the literal to accept every member
(
Closure(int|string $x)handedfn(int $x)fails — the literal can't take a string); - an intersection is checked where it is the expected (super) type — a value must satisfy every member.
As everywhere, an unprovable member keeps the whole leaf gradual: a union or intersection that mentions an unresolved class, a type parameter, or a pseudo-type is accepted rather than falsely rejected.
An intersection used as an incoming (parameter) type also stays gradual (accepted): an intersection of unrelated types is uninhabited, so rejecting it would be unsound.
A DNF signature type (a parenthesised mix such as (A&B)|C) is accepted in
every position — parameter, return, nested — and behaves like any other
unresolvable leaf: it erases with the rest of the signature and stays
gradual (never variance-checked member by member, never the cause of a
rejection). Arity, by-ref-ness, and the other structured slots around a DNF
leaf are still checked as usual. Structuring a DNF into variance-checked
members is a possible future refinement.
An array-sugar leaf (Closure(Item[] $items): int, Closure(): int[])
lowers to array inside a signature — the same lowering T[] gets everywhere
else in xphp — and participates in conformance as array (a gradual leaf, so
it can only ever widen acceptance; the arity around it is still checked). The
Name<Args>[] combination remains unsupported, in signatures as elsewhere.
A Closure(...) signature type is accepted in a parameter, return, or
property position (including nested inside another Closure(...)). A few
positions are not supported yet; each is rejected with a clear,
closure-specific compile error (never a silent miscompile), and each may be
lifted in a future version:
- As a generic type argument —
Box<Closure(int): int>. Wrap the callable in a named type, or use a bare\Closuretype argument if you don't need the signature checked. - As a generic bound —
class C<T : Closure(int): int>. Use a bare\Closurebound (class C<T : \Closure>) if you only need "some closure". - Nested inside a generic argument of a bound —
class C<T : Box<Closure(int): int>>— for the same reason as the two above. - With the
Name<Args>[]array-of-generic combination — e.g.Box<int>[]inside a signature. This shape is unsupported across xphp, not only in signatures. - With an untyped parameter —
Closure($x): int. A signature parameter must carry a type; writeClosure(int $x): int(a truly unconstrained slot can usemixed).
In every case a bare \Closure (no call signature) is always accepted — you
lose the compile-time conformance check but keep a working \Closure type.
- Variance — declaration-site
out T/in Tclass variance: the same "inputs one way, outputs the other" principle, applied to whole specializations via realextendsedges rather than per-signature. - Closures and arrows — the generic
function<T>(...)/fn<T>(...)forms these signatures describe. - Errors — the
xphp.closure_conformancediagnostic. - Test fixtures:
test/fixture/check/closure_conformance/,test/fixture/compile/closure_conformance_grounded_reject/.