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
ccc5d69
refactor(monomorphize): represent an alias body as a DNF AliasBody VO
math3usmartins Aug 6, 2026
dd9e38a
feat(monomorphize): accept intersection and DNF type-alias bodies
math3usmartins Aug 6, 2026
4f8438f
docs(type-aliases): document intersection and DNF bodies
math3usmartins Aug 6, 2026
6bd12e1
fix(monomorphize): dedupe redundant intersection and union alias members
math3usmartins Aug 7, 2026
87c49c2
feat(monomorphize): accept closure-signature type-alias bodies
math3usmartins Aug 7, 2026
bc0a703
docs(type-aliases): document closure-signature bodies
math3usmartins Aug 7, 2026
1022cec
test(monomorphize): cover closure-sig alias parse edge cases
math3usmartins Aug 7, 2026
26efe7b
fix(monomorphize): handle a closure-sig alias reached transitively
math3usmartins Aug 7, 2026
6f992d3
Merge branch 'feat/alias-intersection-dnf' into feature/richer-alias-…
math3usmartins Aug 7, 2026
b9f0c38
Merge branch 'feat/alias-closure-sig' into feature/richer-alias-bodies
math3usmartins Aug 7, 2026
35a340c
docs(type-aliases): sync roadmap, comparison, index, and ADR with the…
math3usmartins Aug 7, 2026
e6701e2
feat(monomorphize): allow a closure signature as a compound alias-bod…
math3usmartins Aug 7, 2026
3ef9b2a
docs(type-aliases): a closure signature can be a compound member
math3usmartins Aug 7, 2026
87fe46f
refactor(monomorphize): tighten closure-in-compound parse for mutatio…
math3usmartins Aug 7, 2026
9e0678a
Merge branch 'feat/alias-closure-in-compound' into feature/richer-ali…
math3usmartins Aug 7, 2026
7db0d64
test(monomorphize): name the closure fixture source after its class
math3usmartins Aug 7, 2026
db1a1c5
fix(monomorphize): make alias member dedup case-insensitive
math3usmartins Aug 7, 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
30 changes: 19 additions & 11 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
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`):
(possibly-generic) head, a **union** (`int|string`), a **nullable** (`?Box`), an
**intersection** (`A&B`), a **DNF** — a union of intersections (`(A&B)|C`) — or a
**closure signature** (`Closure(int): bool`, generic `Mapper<T, R> = Closure(T): R`,
and as a nullable / union / intersection member — `?Closure(...)`, `Foo | Closure(...)`;
erased to a bare `\Closure` and conformance-checked against the signature):
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`),
`Bag<UserId>`), while a compound (union / nullable / intersection / DNF / closure
signature) expands as the whole type of a parameter, property, return, or
class-constant slot — or, as a union/intersection **bound**, all-of for an
intersection and any-of for a union. 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
unsupported-body (`xphp.alias_unsupported_body` — e.g. two closures in one body,
both erasing to `\Closure`), compound-in-non-slot (`xphp.alias_compound_in_non_slot`), distribution-requiring
(`xphp.alias_compound_needs_distribution` — a union nested in an intersection),
scalar-in-intersection (`xphp.alias_scalar_in_intersection`), 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
Expand Down
11 changes: 6 additions & 5 deletions docs/adr/0023-type-alias-declaration-syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,12 @@ and needs no runtime identity.
- 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.
- Trade-off: the initial scope was single-head / union / nullable bodies, **file-local** (an
alias is scoped to its file like a `use` alias, by design — see option D below), delivering a
safe subset with the richer bodies as later work. (That later work landed in v0.4.0:
intersection, DNF, and closure-signature bodies are now supported; compound-in-non-slot
positions remain rejected. See the [caveat](../caveats.md#type-alias-body-and-position-limits)
and [roadmap](../roadmap.md) for the current state.)

### Confirmation

Expand Down
52 changes: 34 additions & 18 deletions docs/caveats.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,22 +94,34 @@ behavior, only makes the type explicit.
[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.
a nullable (`?Box`), an intersection (`A & B`), a DNF (`(A & B) | C`), and a closure
signature (`Closure(int): bool`, and as a nullable / union / intersection member) body
are all supported; parameters may carry defaults and bounds. The remaining limits are
on the body shape (at most one closure per body, distribution) and the positions a
compound alias can take.

### ❌ 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 Fn = Closure(int): int; // ✓ closure signature — erases to \Closure
type N = ?Closure(int): bool; // ✓ nullable closure — erases to ?\Closure
type U = A | Closure(int): int;// ✓ union member — erases to \A|\Closure
type Two = (Closure(int): bool) | (Closure(string): int); // ✗ xphp.alias_unsupported_body — two
// closures both erase to \Closure (PHP forbids \Closure|\Closure)

// No distribution: a union nested inside an intersection:
type Bad = (A | B) & C; // ✗ xphp.alias_compound_needs_distribution
// (write it in DNF: (A & C) | (B & C))
type Nope = int & A; // ✗ xphp.alias_scalar_in_intersection — scalar in intersection

// A compound alias (union / intersection / nullable / DNF) 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
$b = new Num(); // ✗ compound alias in `new` / extends
// (as a *bound* a compound DOES expand — `type B<T : A & Named>` is all-of.)
```

### 🔒 File-local (by design)
Expand Down Expand Up @@ -138,19 +150,23 @@ 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.
A compound body (union / intersection / nullable / DNF / closure signature) lowers
cleanly into a PHP type node, but only as the whole type of a param / property /
return / class-constant slot — it has no single identity to hash or anchor, so
anywhere else (a generic argument, `new`, `extends`, or nested in another compound)
it is rejected loudly rather than mis-compiled. What stays out: **two closures in one
body** (both erase to the same `\Closure`, and PHP forbids a duplicate `\Closure|\Closure`),
and a shape that would need **distribution** (`(A|B)&C`) — xphp requires you to write the
disjunctive normal form yourself rather than distribute (and expand) silently. These are
"make the safe subset solid first" trades. 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*.
- A closure signature composes freely as a nullable / union / intersection member, but
a body may hold at most one — every closure erases to `\Closure`. For a `(A|B)&C` body,
write the DNF `(A&C)|(B&C)`.
- 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
Expand Down
6 changes: 4 additions & 2 deletions docs/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,10 @@ The `json` and `github` formats tag each diagnostic with a stable code:
| `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) |
| `xphp.alias_unsupported_body` | a type-alias body that is not a single head, a union, an intersection, a nullable, a DNF, or a closure signature (which may be a member of a union / intersection / nullable) — e.g. **two** closures in one body (`(Closure(int): bool) \| (Closure(string): int)`), since every closure erases to the same `\Closure` and PHP forbids a duplicate `\Closure\|\Closure`, or genuine garbage |
| `xphp.alias_compound_in_non_slot` | a compound type alias (union / intersection / nullable / DNF) 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) |
| `xphp.alias_compound_needs_distribution` | a type-alias body with a union nested inside an intersection (`(A \| B) & C`, or an intersection member that expands to a union) — not supported; rewrite it in disjunctive normal form (`(A & C) \| (B & C)`) or introduce a named type for the union |
| `xphp.alias_scalar_in_intersection` | a type-alias intersection whose member is a scalar or built-in type (`int & A`, or a `T & …` where `T` is substituted with a scalar) — PHP forbids scalars in an intersection; only class-like types can be intersected |
| `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
8 changes: 5 additions & 3 deletions 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 | ⚠️ (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) | ❌ | ✅ | ✅ | ✅ |
| Generic type aliases | ⚠️ (compile-time substitution; single-head / union / nullable / intersection / DNF / closure-signature bodies — a closure may be a nullable / union / intersection member too — parameter defaults + bounds, aliases usable as bounds; aliases are file-local, and a body may hold at most one closure) | ❌ | ✅ | ✅ | ✅ |
| 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 Expand Up @@ -193,8 +193,10 @@ 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](../roadmap.md).
with PHP's existing union types. **Shipped** — single-head, union,
nullable, intersection, DNF, and closure-signature bodies, file-local,
with parameter defaults and bounds. See the
[type aliases tour](../syntax/type-aliases.md).

### Wildcard / `*` (use-site existential)

Expand Down
27 changes: 18 additions & 9 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ timeline
: marker interface per template
Type aliases
: compile-time substitution, file-local
: single-head union and nullable bodies
: single-head union nullable intersection and DNF bodies
: closure-signature bodies erased to Closure
: parameter defaults and bounds
Developer experience
: RFC-aligned call-site syntax
Expand Down Expand Up @@ -237,19 +238,27 @@ upcoming one.
- `type Name<A, B> = Body;` and `type Name = Body;` — a compile-time
substitution expanded into its body before specialization, with no
runtime existence (the emitted PHP never mentions the alias).
- Single-head, **union** (`int|string`), and **nullable** (`?Box`) bodies.
A single head expands in every type position (incl. as a generic
argument, `Bag<UserId>`); a union/nullable expands as the whole type of a
slot. Composes with nested and concrete-instantiation aliases.
- Single-head, **union** (`int|string`), **nullable** (`?Box`),
**intersection** (`A & B`), **DNF** (`(A & B) | C`), and
**closure-signature** (`Closure(int): bool`, generic `Mapper<T, R> =
Closure(T): R`, and as a nullable / union / intersection member) bodies. A
single head expands in every type position (incl. as a generic argument,
`Bag<UserId>`); a compound (union / nullable / intersection / DNF / closure
signature) expands as the whole type of a slot — a closure signature erasing
to a bare `\Closure` carrying the signature for conformance, grounded per
specialization. Composes with nested and concrete-instantiation aliases;
redundant intersection/union members are deduped, and at most one closure may
appear in a body (every one erases to `\Closure`).
- 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), like a generic class.
A union/intersection alias as a bound is any-of / all-of.
- **File-local by design**: an alias is visible only in the file that
declares it (like a `use` alias); declare it per file to share it.
- Cyclic, arity-mismatched, class-colliding, duplicate, unsupported-body
(intersection / DNF / closure), compound-in-non-slot, and
bound-violating uses are loud compile errors in both `compile` and
`check`, each with a stable code.
- Cyclic, arity-mismatched, class-colliding, duplicate, unsupported-body (two
closures in one body), compound-in-non-slot, distribution-requiring
(`(A|B)&C`), scalar-in-intersection, and bound-violating uses are loud
compile errors in both `compile` and `check`, each with a stable code.
- See the [type aliases](syntax/type-aliases.md) tour and the
[body / position limits caveat](caveats.md#type-alias-body-and-position-limits).

Expand Down
2 changes: 1 addition & 1 deletion docs/syntax/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ first.
| [Pseudo-types](pseudo-types.md) | `self<T>` / `static<T>` / `parent<T>` and the `new self::<T>(...)` form |
| [Turbofish](turbofish.md) | All four call-site shapes plus variable and empty turbofish |
| [Array sugar](array-sugar.md) | `T[]` shorthand |
| [Type aliases](type-aliases.md) | `type Pair<A, B> = …;`, compile-time substitution, file-local; union/nullable bodies, parameter defaults + bounds |
| [Type aliases](type-aliases.md) | `type Pair<A, B> = …;`, compile-time substitution, file-local; union / nullable / intersection / DNF / closure-signature bodies, parameter defaults + bounds |
| [Exceptions](exceptions.md) | Generic exceptions, `catch (HttpError<NotFound> $e)`, bare and union catch |

## Quick reference card
Expand Down
Loading
Loading