Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
4e39e0d
feat(monomorphize): strip type-alias declarations at scan
math3usmartins Jul 30, 2026
5c0302c
feat(monomorphize): expand type-alias uses before specialization
math3usmartins Jul 30, 2026
585d933
feat(monomorphize): dedicated diagnostics for type-alias rejections
math3usmartins Jul 30, 2026
729e16c
docs(type-aliases): document the feature, ADR-0023, roadmap, changelog
math3usmartins Jul 30, 2026
89bab9d
feat(monomorphize): union and nullable type-alias bodies
math3usmartins Jul 30, 2026
396b7e0
feat(monomorphize): whole-program (cross-file) type aliases
math3usmartins Jul 30, 2026
b5df8cc
docs(type-aliases): document union/nullable bodies and cross-file
math3usmartins Jul 30, 2026
0dbd663
feat(monomorphize): apply defaults for generic type-alias parameters
math3usmartins Jul 30, 2026
d97d702
feat(monomorphize): enforce generic type-alias parameter bounds
math3usmartins Jul 30, 2026
5423490
docs(type-aliases): document parameter defaults and bounds
math3usmartins Jul 30, 2026
34759a3
fix(monomorphize): drop a file's alias-bound obligations when its par…
math3usmartins Jul 30, 2026
79bcc03
fix(monomorphize): expand a type alias used as a parameter bound
math3usmartins Jul 31, 2026
5743023
docs(type-aliases): note alias-in-bound support and the single-namesp…
math3usmartins Jul 31, 2026
daec172
docs(type-aliases): sync comparison grid, error catalog, and feature …
math3usmartins Jul 31, 2026
b6dfa6b
feat(monomorphize): make type aliases file-local (drop cross-file pre…
math3usmartins Jul 31, 2026
fef5546
docs(type-aliases): reframe file-locality as by-design, not a limitation
math3usmartins Jul 31, 2026
1c4694f
fix(monomorphize): correct alias-body message + guard argument-path c…
math3usmartins Aug 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Type aliases.** Give a type a reusable name, in two forms:
`type Name<A, B> = Body;` (generic) and `type Name = Body;` (non-generic). An
alias is a compile-time substitution — expanded into its body before
specialization, with no runtime existence, so the emitted PHP never mentions the
alias. Bodies may be a single
(possibly-generic) head, a **union** (`int|string`), or a **nullable** (`?Box`):
a single head expands in every type position (incl. as a generic argument,
`Bag<UserId>`), while a union/nullable expands as the whole type of a parameter,
property, return, or class-constant slot. Aliases compose (nested and
concrete-instantiation, `type UserMap = Pair<int, User>`); parameters carry
**defaults** (`type P<A, B = A>` — a use may omit trailing defaulted arguments)
and **bounds** (`type B<T : Named>` — an argument that violates the bound is a
compile error; the bound may itself name an alias), like a generic class. An alias
is **file-local** — visible only in the file that declares it, like a `use` alias.
A cyclic (`xphp.alias_cycle`), arity-mismatched (`xphp.alias_arity`),
class-colliding (`xphp.alias_class_collision`), duplicate (`xphp.alias_duplicate`),
unsupported-body (`xphp.alias_unsupported_body` — intersection / DNF / closure),
compound-in-non-slot (`xphp.alias_compound_in_non_slot`), or bound-violating
(`xphp.bound_violation`) alias is a loud error in both `xphp compile` and
`xphp check`. See [type aliases](docs/syntax/type-aliases.md).
- **Type-argument inference (optional turbofish).** A generic call or `new` whose
type parameters are determined by the argument values no longer needs the `::<>`
turbofish: `identity(5)` infers `identity::<int>`, `new Box($product)` infers
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,10 @@ genuinely [hard work](https://thephp.foundation/blog/2024/08/19/state-of-generic
The object model that's served the ecosystem for two decades doesn't bend
easily.

Supporting generics proves that the compile-to-vanilla model handles non-trivial
type-system additions. The remaining features are on
Supporting generics — and now type aliases — proves that the compile-to-vanilla
model handles non-trivial type-system additions. Further features are on
the [roadmap](docs/roadmap.md):
type aliases, literal types, mapped and conditional types to name a few.
literal types, mapped and conditional types to name a few.

## Quick start

Expand Down
115 changes: 115 additions & 0 deletions docs/adr/0023-type-alias-declaration-syntax.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# 23. Type-alias syntax is the declaration form `type Name<…> = Body`

- Status: Accepted — 2026-07

## Context and Problem Statement

xphp adds type aliases — a name for a type, expanded at compile time (see
[type aliases](../syntax/type-aliases.md)). A first-class goal is that an alias may be
**generic** (`type Pair<A, B> = Map<A, List<B>>`), not only a name for a fixed type.

PHP itself has a live but unsettled proposal, [PHP RFC: Type
Aliases](https://wiki.php.net/rfc/typed-aliases), which uses an *import* form
(`use type int|float as Number;`) and explicitly lists parameterized (generic) aliases
under "Future Scope" — so there is no PHP-blessed syntax for the generic case xphp needs.
xphp must therefore choose a surface, ideally one that stays forward-compatible with where
PHP is most likely to land.

## Decision Drivers

- **Must express generic aliases**, since that is a primary goal.
- Forward-compatibility with a plausible future PHP syntax.
- Fit xphp's existing angle-bracket surface (`Foo<T>`, the `::<>` turbofish).
- Correctness first: no silent miscompile; an alias must lower to exactly what its body
would have.

## Considered Options

- **A — declaration form `type Name<…> = Body;`** (with the non-generic case being the
zero-parameter `type Name = Body;`). The form used by TypeScript, Rust, Scala, and — most
relevantly — **Hack**, PHP's closest relative.
- **B — import form `use type Body as Name;`** (PHP's current RFC).
- **C — a distinct keyword** (`typedef` / `typealias`).
- **D — a runtime, autoloadable alias symbol** (an alias that exists at runtime and via
reflection), rather than a pure compile-time substitution.

## Decision Outcome

Chosen: **A — the declaration form `type Name<…> = Body`, resolved as a compile-time
substitution.**

The import form (B) is eliminated by the generic requirement: `use type Body as Name` has
no place to put parameters on `Name` (`use type Map<A, List<B>> as Pair<A, B>` is
ambiguous), which is almost certainly why PHP deferred generic aliases. The declaration
form is the *only* one of the two that expresses both cases with a single rule, and it is
what every language that supports generic aliases uses. Hack — the closest precedent to
xphp's situation — spells it exactly `type Name<T> = …;`. It also fits xphp's own
angle-bracket surface. A distinct keyword (C) buys nothing over `type` and is further from
that precedent.

Aliases are a **compile-time substitution** with no runtime existence (not option D). The
long-standing blocker for PHP here — how to autoload/define a runtime alias symbol — simply
does not arise for xphp: it is a whole-program, build-time transpiler
([ADR-0002](0002-build-time-transpiler.md)), so an alias is expanded before specialization
and needs no runtime identity.

### Consequences

- Good: one grammar covers generic and non-generic aliases; it matches the cross-language
and Hack consensus and xphp's existing syntax; expansion reuses the monomorphizer with no
new emission path or runtime cost.
- Trade-off: for the *generic* case xphp defines surface ahead of PHP (which deferred it),
a bet on the declaration-form consensus. The non-generic import form (`use type … as`)
could be added later as a parity synonym without disturbing this decision.
- Trade-off: the delivered scope is single-head / union / nullable bodies, **file-local** (an
alias is scoped to its file like a `use` alias, by design — see option D below);
intersection / DNF / closure bodies and compound-in-non-slot positions are still
rejected (see the [caveat](../caveats.md#type-alias-body-and-position-limits)) — a safe
subset, with the richer bodies as later work.

### Confirmation

The scanner recognizes `type Name[<…>] = SingleHead;` and strips it; expansion is exercised
end to end by `test/fixture/compile/type_aliases/` (a runtime fixture that executes the
compiled output and asserts no alias name survives) and the `TypeAliasIntegrationTest`
cases. Every rejection carries a stable code (`xphp.alias_cycle`, `xphp.alias_arity`,
`xphp.alias_class_collision`, `xphp.alias_duplicate`, `xphp.alias_unsupported_body`) and is
verified in both `compile` and `check`.

## Pros and Cons of the Options

### A — declaration form `type Name<…> = Body`

- Good: expresses generic and non-generic aliases with one rule; matches Hack + TS + Rust +
Scala; fits xphp's angle-bracket surface.
- Bad: leads PHP for the generic case (PHP has only the import form, and only for
non-generic aliases so far).

### B — import form `use type Body as Name`

- Good: matches PHP's current RFC for the non-generic case; forward-compatible there.
- Bad: cannot carry type parameters, so it cannot express generic aliases — the primary
goal.

### C — distinct keyword (`typedef` / `typealias`)

- Good: unambiguous keyword.
- Bad: no advantage over `type`; further from the Hack precedent and the cross-language norm.

### D — runtime / autoloadable alias symbol

- Good: reflection and cross-file use "for free".
- Bad: imports PHP's unsolved autoloading/definition problem for no benefit — xphp expands
aliases at build time and needs no runtime symbol.

## More Information

- [Type aliases](../syntax/type-aliases.md) and the
[file-local / single-head caveat](../caveats.md#type-alias-body-and-position-limits).
- [ADR-0001](0001-monomorphization-over-type-erasure.md) — monomorphization;
[ADR-0002](0002-build-time-transpiler.md) — build-time transpiler (why a runtime alias
symbol is unnecessary).
- [PHP RFC: Type Aliases](https://wiki.php.net/rfc/typed-aliases) (import form; generic
aliases in Future Scope); [PHP RFC: Bound-erased generic
types](https://wiki.php.net/rfc/bound_erased_generic_types) (the `Foo<T>` surface xphp
tracks). Hack spells the declaration form `type Name<T> = …;` (and `newtype`).
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,4 @@ should be added here as a new numbered file; copy
| [0020](0020-diagnose-and-restructure-self-reintroducing-specialization.md) | Diagnose and restructure self-reintroducing specialization (erased seam deferred) | Accepted |
| [0021](0021-compile-runs-the-check-gate-by-default.md) | `xphp compile` runs the check gate by default | Accepted |
| [0022](0022-bounds-are-upper-only.md) | Bounds are upper-only (no supertype/lower bounds) | Accepted |
| [0023](0023-type-alias-declaration-syntax.md) | Type-alias syntax is the declaration form `type Name<…> = Body` | Accepted |
69 changes: 69 additions & 0 deletions docs/caveats.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,75 @@ wherever inference can't see the type. It's always accepted, and an inferred
call is identical to the turbofished one — so adding a turbofish never changes
behavior, only makes the type explicit.

## Type-alias body and position limits

[Type aliases](syntax/type-aliases.md) are a compile-time substitution, and are
**file-local by design** — an alias is visible only in the file that declares it,
like a PHP `use` alias. A single head (`Ident`, `Box<int>`), a union (`int|string`),
and a nullable (`?Box`) body are all supported; parameters may carry defaults and
bounds. Two limits remain, both on the body shape and its position.

### ❌ What doesn't work

```php
type Both = A & B; // ✗ xphp.alias_unsupported_body — intersection
type Dnf = (A & B) | C; // ✗ xphp.alias_unsupported_body — DNF
type Fn = Closure(int): int; // ✗ xphp.alias_unsupported_body — closure signature

// A union / nullable alias is only usable as the WHOLE type of a slot:
type Num = int|string;
function f(Num $n): void {} // ✓ whole param slot
function g(Bag<Num> $x): void {} // ✗ xphp.alias_compound_in_non_slot — generic argument
function h(Num&Extra $x): void {} // ✗ nested in another intersection/union
$b = new Num(); // ✗ compound alias in `new` / extends / a bound
```

### 🔒 File-local (by design)

An alias is scoped to its file, like a `use` alias — not visible in another file:

```php
// File Types.xphp
type UserId = Ident;
type Pair<A, B> = Dict<A, B>;
// File Other.xphp — a DIFFERENT file
function f(): UserId { … } // UserId is a plain unknown type here — not expanded
function g(): Pair<int, User> { … } // ✗ Pair is not visible — an undefined template
```

To share a vocabulary, **declare the alias in each file that uses it** (a zero-cost
substitution) or reference the underlying type directly. Because scoping is
per-file there is no cross-file duplicate or collision to detect — two files each
with `type Id = …` are simply independent local aliases. (Same-file duplicate /
class-collision *are* caught — `xphp.alias_duplicate` / `xphp.alias_class_collision`.)

An alias's body, bounds, and defaults resolve in the namespace that **uses** it.
Under one `namespace {}` per file (the PSR norm) that is always the declaring
namespace; in a file with multiple namespace blocks a bare name can mis-resolve —
keep one namespace per file, or fully-qualify.

### Why

The body is limited to a single head, a flat union, or a nullable because those
lower cleanly into a PHP type node. An intersection or DNF pulls in *distribution*
(`(A|B)&C → (A&C)|(B&C)`), and a union/nullable has no single identity to hash or
anchor, so it is representable only as the whole type of a param / property /
return / class-constant slot — anywhere else it is rejected loudly rather than
mis-compiled. These are "make the safe subset solid first" trades, candidates to
lift later. File-locality, by contrast, is a deliberate choice — an alias is a
local naming convenience, like `use`, not a whole-program symbol — not a limit.

### ✅ Workaround

- For an intersection / DNF / closure body, write the type directly, or wrap it in
a named class or interface and alias *that*.
- Use a union/nullable alias as the whole type of a slot; write the union directly
where you need it as a generic argument or nested in another compound type.
- Declare an alias in each file that uses it (a zero-cost substitution), or
reference the underlying type directly across files.

---

## `$this`-capturing arrows and closures rejected

### ❌ What doesn't work
Expand Down
6 changes: 6 additions & 0 deletions docs/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ The `json` and `github` formats tag each diagnostic with a stable code:
| `xphp.unschedulable_covariant_upcast` | a value is upcast to a covariant *interface* whose element-consuming method (`contains<U : E>`) needs a concrete implementation at the supertype argument that can neither be inherited through the covariant chain nor emitted directly onto the upcast source. Direct emission already covers the cases where inheritance can't carry it (the implementing class has another `extends` parent, implements only a parent of the interface, or reorders the clause); the upcast fails only when **no** emittable class body exists (a truly abstract or trait-only method), the method's **return type** names the element parameter (the widened argument would escape through a narrower return), or its parameters are bounded by **different** enclosing parameters (no single member can be derived). Provide a concrete implementation on a class — move a trait body onto the covariant base, or give the method a non-element return type |
| `xphp.closure_conformance` | a closure literal returned against a `Closure(...)` type doesn't conform to it — its parameters aren't wide enough, its return isn't narrow enough, its by-reference-ness differs, or its arity is incompatible |
| `xphp.parse_error` | the source can't be parsed — either a PHP syntax error after the generic strip pass, or a parse-time xphp rejection (a variance marker on a method/closure, a malformed generic default, a generic clause on a `use` import, a `Closure(...)` signature with a defaulted or untyped parameter, or a `Closure(...)` signature type in an unsupported position such as a generic argument or bound), reported at the offending line |
| `xphp.alias_cycle` | a [type alias](syntax/type-aliases.md) defined, directly or transitively, in terms of itself — through its body (`type A = B; type B = A;`) or a parameter bound (`type A<T : A>`) |
| `xphp.alias_arity` | a type-alias use whose type-argument count is outside the alias's accepted range — fewer than the required (default-less) parameters or more than it declares (`type P<A, B> = …;` used as `P<int>`; a default widens the range) |
| `xphp.alias_class_collision` | a type-alias name collides with a class, interface, or trait of the same name in the same file (no silent shadowing) |
| `xphp.alias_duplicate` | the same type-alias name is declared more than once in a file |
| `xphp.alias_unsupported_body` | a type-alias body that is not a single head, a flat union, or a nullable — an intersection (`A & B`), a DNF (`(A & B) \| C`), or a closure signature (`Closure(int): int`) |
| `xphp.alias_compound_in_non_slot` | a union / nullable type alias used somewhere other than the whole type of a parameter, property, return, or class-constant slot (e.g. as a generic argument or nested in another compound type) |
| `phpstan.*` | a PHPStan finding in the compiled output, mapped back to the template declaration (the code is `phpstan.` + PHPStan's own identifier, e.g. `phpstan.return.type`; a finding that carries no identifier falls back to the literal `phpstan.error`) — present only when the PHPStan pass runs |
| `phpstan.unavailable` | (Warning) no phpstan binary was found, so the PHPStan pass was skipped |
| `phpstan.run_failed` | (Warning) phpstan was found but couldn't complete (e.g. a config error) |
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/comparison.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ than erasure can.
| Reified T at runtime | ✅ (via AOT) | ❌ (erased) | ❌ | ⚠️ (`inline fun` only — can't reify a class type parameter) | ✅ (monomorphic) |
| `instanceof OriginalFqn` works | ✅ | ✅ (trivially: only one class exists at runtime) | n/a | n/a | n/a |
| Real subtype edges between specializations | ⚠️ (common case works; some covariant upcasts are unschedulable or may not converge) | ❌ (erased) | n/a | n/a | n/a |
| Generic type aliases | ❌ | ❌ | ✅ | ✅ | ✅ |
| Generic type aliases | ⚠️ (compile-time substitution; single-head / union / nullable bodies, parameter defaults + bounds, aliases usable as bounds; aliases are file-local, and intersection / DNF / closure-signature bodies aren't supported) | ❌ | ✅ | ✅ | ✅ |
| Wildcard / `*` (use-site existential) | ⚠️ partial (via marker) | n/a (erased) | ⚠️ via `any` (bivariant escape hatch — loses type discipline) | ✅ (`Box<*>`) | n/a |
| Use-site variance | ❌ | ❌ | ❌ | ✅ | n/a |
| Variadic generics | ❌ | ❌ | ✅ | ❌ | ⚠️ tuples |
Expand Down
6 changes: 3 additions & 3 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,6 @@ and the gap is explicit in [comparison](guides/comparison.md) and

Generics are the first substantial chunk of work in xphp, but the
roadmap is much broader. See [roadmap](roadmap.md) for what's
shipped and for the discovery items under exploration (type aliases,
mapped types, variadic generics, generic enums, source maps, AST
macros, and more).
shipped — generics and, now, [type aliases](syntax/type-aliases.md) —
and for the discovery items under exploration (mapped types, variadic
generics, generic enums, source maps, AST macros, and more).
Loading
Loading