xphp uses monomorphization -- same as Rust, different from Kotlin, TypeScript, and the PHP RFC (all three erase the type parameter at runtime). That single choice ripples through every other trade-off below: features like reified T and real subtype edges between specializations come almost for free; features like full existential wildcards or type-erasure-tolerant runtime reflection are genuinely harder.
If you're new to the model, the short version: every distinct
(template, args) pair becomes its own real class at compile time.
Box<int> and Box<string> are unrelated classes that happen to
share a marker interface. Read how it works for the
end-to-end walk.
The RFC column refers to
PHP RFC: bound-erased generic types
— the surface xphp tracks. xphp's syntax stays compatible with the
RFC; runtime semantics diverge where monomorphization gives you more
than erasure can.
| Feature | xphp | RFC | TS | Kotlin | Rust |
|---|---|---|---|---|---|
| Generic classes / interfaces / traits | ✅ | ✅ | ✅ | ✅ | ✅ |
| Generic functions / methods | ✅ | ✅ | ✅ | ✅ | ✅ |
| Generic closures + arrow functions | ✅ | ✅ | ✅ | ✅ | ✅ |
Typed closure signatures (Closure(int): bool) |
✅ (erases to \Closure; literal conformance checked at compile time) |
✅ (runtime-lenient) | ✅ (function types) | ✅ ((Int) -> Bool) |
✅ (Fn(i32) -> bool) |
| Upper bounds | ✅ | ✅ | ✅ | ✅ | ✅ |
| Multiple bounds (intersection) | ✅ | ✅ | ✅ | ✅ | ✅ |
| Union bounds + DNF | ✅ | ✅ | ✅ | ❌ (intersection only via where) |
n/a |
F-bounded recursion (T : Box<T>) |
✅ | ✅ | ✅ | ✅ | ✅ |
| Default type parameters | ✅ | ✅ | ✅ | ✅ | ✅ |
Declaration-site variance (out T / in T) |
✅ | ✅ | ✅ | ✅ | |
| Inner-template variance composition | ✅ | ✅ | ✅ | ✅ | ✅ |
| Reified T at runtime | ✅ (via AOT) | ❌ (erased) | ❌ | ✅ (inline) | ✅ (monomorphic) |
instanceof OriginalFqn works |
✅ | ✅ (trivially: only one class exists at runtime) | n/a | n/a | n/a |
| Real subtype edges between specializations | ✅ | ❌ (erased) | n/a | n/a | n/a |
| Generic type aliases | ❌ | ❌ | ✅ | ✅ | ✅ |
Wildcard / * (use-site existential) |
n/a (erased) | any (bivariant escape hatch — loses type discipline) |
✅ (Box<*>) |
n/a | |
| Use-site variance | ❌ | ❌ | ❌ | ✅ | n/a |
| Variadic generics | ❌ | ❌ | ✅ | ❌ | |
| Generic enums / sum types | ❌ | ❌ | ✅ | ✅ | ✅ |
| Per-arg specialization | ❌ | ❌ (erasure) | ❌ | ❌ | |
| Associated types | ❌ | n/a | ❌ | ❌ | ✅ |
T[] array sugar |
✅ | ❌ | ✅ | ❌ | ❌ |
n/a means the feature doesn't fit the language's design — there's
no comparable concept to align to. For the RFC, the (erased) notes
mark features that simply can't exist under bound erasure: there are
no specialized classes at runtime, so subtype edges, reified-T
operations, and a wildcard sigil all lose their meaning.
Three features fall out of the monomorphization model that the bound-erasure model trades away for its own benefits (single runtime class per template, no transitive class explosion, simpler ABI surface). Neither path is universally better; the trade-off lands differently depending on what you optimize for.
function decode<T>(string $json): T {
$data = json_decode($json, true);
return new T(...$data); // T resolves to the concrete class at compile time
}
$user = decode::<User>($payload);Inside the specialized body, T literally becomes User. instanceof T,
T::class, and new T(...) all work because there's no T at
runtime — just the concrete class name substituted in.
⚠️ Code relying on reified T won't port directly to a future RFC-aligned runtime. See caveats.
class Producer<out T> {
public function get(): T { /* ... */ }
}With Banana extends Fruit, xphp emits the specialized class
Producer<Banana> as a real extends Producer<Fruit>. PHP's native
LSP carries the relationship: a function expecting
Producer<Fruit> accepts a Producer<Banana> argument without any
runtime check.
Erasure-based runtimes can't do this because their specializations don't exist as distinct classes.
Every generic template emits a marker interface at the original FQN.
$x instanceof App\Box returns true for Box<int>, Box<string>,
and any other specialization, even though they're physically
unrelated classes. You get the "polymorphic over T" mental model
without losing instance checks.
The features marked ❌ in the grid above are conscious deferrals, not oversights. Each one has a sketch of intent below.
type Result<T, E> = Success<T> | Failure<E>;
type Pair<A, B> = array{first: A, second: B};Substitution at parse time; no new nominal types. Composes naturally with PHP's existing union types. On the roadmap as a Generic surface item.
function inspect(Box $b): void; // any Box, regardless of Txphp has a wildcard-shaped type-hint position: the bare template
name without <> accepts any Box<X> because every specialization
implements the marker interface emitted at the original FQN.
The closest analogues are Java's Box<?> and Kotlin's Box<*> —
both are use-site existential types where the type system still
knows the upper bound for T, so reads return that bound. xphp's
marker is weaker: there's no runtime witness for T, and the marker
declares no methods. You can pass instances through and
instanceof-check, but you can't dispatch methods on the bare
parameter.
Closing the gap means populating the marker with upper-bound-typed read-only methods. See runtime semantics for the runtime side, and the roadmap for the work that would lift this from partial to a real existential.
⚠️ TypeScript has no true use-site existential. Two near-misses:
Box<unknown>is justT = unknown(the top type). Whether it accepts aBox<string>depends onBox's declared variance (TS 4.7+'sout Tmarkers), and even then only one direction. Type-safe but not a wildcard.Box<any>works becauseanyis bivariant — it accepts anyBox<X>regardless of declared variance. This is the practical "I don't care about T" sigil, but reads come back typedany, which propagates and disables type-checking on whatever you do with the value.Neither matches Java's
Box<?>or Kotlin'sBox<*>, which give bounded reads at the upper bound while keeping type discipline.Similarly,
mixedin xphp is not a wildcard sigil — it's a regular scalar type.Box<mixed>specializes to a distinct class whereT = mixedis baked into every signature, sibling toBox<int>andBox<string>rather than a supertype of them.
Kotlin's Box<out T> / Box<in T> at a usage site (rather than at
the declaration). The RFC doesn't include this; we follow.
function pipe<...Ts, R>(callable ...$fs): Closure;Correctly-typed array_map, builder chains, and pipelines depend on
this. Significantly more invasive than the rest of the surface.
enum Option<T> {
case Some(T);
case None;
}PHP enums don't carry per-case data today, so this needs a deeper
language change. The combination of generic + sum types unlocks
pattern matching, exhaustive match, and monadic chaining.
class Container<T> {
public function dump(): string { return serialize($this->items); }
@specialize(T = int)
public function dump(): string { return implode(',', $this->items); }
}Rust nightly's impl<T> Foo + impl Foo for i32 pattern. The
monomorphization model makes it conceptually natural — the compiler
already emits a different body per T; the question is what syntax
selects which body.
- You already write PHP and want generics that PHP devs understand: xphp fits without forcing a runtime change.
- You want erasure-based runtime semantics so code is forward-portable to a future RFC-aligned PHP runtime: avoid reified T (see caveats). Most of the surface ports cleanly.
- You want full type-system depth (mapped/conditional/dependent types, associated types, variadic generics): TypeScript still leads here. xphp's roadmap tracks several of these as long-term work, but none are short-term targets.
- You want maximum runtime performance with zero type overhead: Rust's monomorphization gives you what xphp gives you, but inside a compiled language with no garbage collector.