Skip to content

feat(monomorphize): richer type-alias bodies (intersection, DNF, closure signatures) - #35

Open
math3usmartins wants to merge 17 commits into
0.4.xfrom
feature/richer-alias-bodies
Open

feat(monomorphize): richer type-alias bodies (intersection, DNF, closure signatures)#35
math3usmartins wants to merge 17 commits into
0.4.xfrom
feature/richer-alias-bodies

Conversation

@math3usmartins

Copy link
Copy Markdown
Member

Additions that bring type-alias bodies to parity with directly-written slot types:

  • Intersection & DNF bodies — A & B, (A & B) | C
  • Closure-signature bodies — Closure(int): bool, generic Mapper<T, R>
  • Closure signatures as compound members?Closure(...), Foo | Closure(...), Foo & Closure(...)

math3usmartins and others added 16 commits August 6, 2026 22:52
Migrate the type-alias body from a flat union list<TypeRef> to an
AliasBody value object wrapping a DNF list<list<TypeRef>> (union of
intersection-clauses): a single head is [[X]], a union [[A],[B]], and
?X desugars to [[X],[null]]. The two-condition single-head predicate
(one clause, one leaf) lives on the VO so it stays in one place and
carries mutation coverage; every expander consumer — the slot path,
the generic-argument path, and the bound path — routes through it.

Behavior-preserving: only union / nullable / single-head bodies are
read, so every clause is single-leaf and the emitted PHP is unchanged.
This is the seam that makes intersection / DNF bodies an easy addition.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A type-alias body may now be an intersection (`type Both = A & B;`) or a
DNF — a union of intersections (`type Dnf = (A & B) | C;`) — expanded, as
any compound body, only as the whole type of a param / property / return /
class-constant slot, and as all-of / any-of when named as a bound.

The body is read through the shared bound-expression reader and normalized
to DNF; a shape that would need distribution (a union nested in an
intersection, `(A|B)&C`, whether written directly or reached by expanding a
union alias inside an intersection) is rejected with
`xphp.alias_compound_needs_distribution` rather than distributed. A scalar
or built-in member in an intersection — including one revealed only after a
type-parameter substitution (`type Pair<T> = T & Countable; Pair<int>`) — is
rejected at emit time with `xphp.alias_scalar_in_intersection`, since PHP
forbids it. A nested intersection alias flattens by `&`-associativity, and a
cycle through an intersection member is still a clean `xphp.alias_cycle`.

Bounds that name an intersection/DNF alias expand to BoundIntersection /
BoundUnion, so `class Box<T : A & B>` is all-of (an argument implementing
only one member is rejected). Single-head / union / nullable bodies are
unchanged. Closure-signature bodies remain the one unsupported shape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lift the caveats, syntax tour, error catalog, and changelog to reflect
that intersection and DNF alias bodies are supported (whole-slot, all-of /
any-of as a bound); only closure-signature bodies remain unsupported. Add
the xphp.alias_compound_needs_distribution and xphp.alias_scalar_in_intersection
codes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A duplicate member in an emitted intersection or union — `A&A`, or a
composed `type Outer = Inner & B` where `Inner = A & B` emitting `A&B&B`,
or `A|A` — is a PHP "Duplicate type is redundant" PARSE fatal that takes
down the whole generated file. Dedupe members by canonical name at emit
(identity-preserving: `A&A` ≡ `A`, `A&B&B` ≡ `A&B`, `A|A` ≡ `A`), so a
composed alias that reintroduces a member collapses cleanly instead of
emitting an unparseable file. The scalar-in-intersection guard already
covered the analogous scalar fatal; this closes the duplicate case.

Found in code review. Subtype-redundancy (`A&B` with `B extends A`) is
left as-is — PHP accepts it, only exact-name duplicates fatal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A type-alias body may now be a closure signature —
`type Handler = Closure(int $x): bool;` — and, generic,
`type Mapper<T, R> = Closure(T): R;`. Used as the whole type of a
param / property / return slot it erases to a bare `\Closure`, carrying
the parsed signature on ATTR_CLOSURE_SIG so the existing conformance
validator checks call sites and factory returns against it — identical
to a directly-written `Closure(...)` in that slot. A generic closure-sig
alias, or one whose signature references an enclosing class type
parameter, grounds per specialization through the Specializer's existing
ATTR_CLOSURE_SIG handling; compile and check agree.

The body is recognized via the ungated closure-signature core
(findClosureSigEnd + buildClosureSignature) — the gated scanner requires
a following `$var`/return slot, which a `;`-terminated alias body is not —
and stored on the AliasBody value object as a signature variant. Used
anywhere other than a whole slot (a generic argument, `new`, a bound) it
is `xphp.alias_compound_in_non_slot`, never an un-`new`-able `new \Closure()`.
A closure signature combined with a union (`A | Closure(...)`) or nullable
(`?Closure(...)`) stays unsupported.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lift the caveats, syntax tour, error catalog, and changelog: a closure
signature is now a supported alias body (erased to `\Closure`, conformance-
checked, grounded per specialization for a generic one). Only a closure
combined with a union/nullable (`A | Closure(...)`, `?Closure(...)`) stays
unsupported.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pin the closure-signature body recognition: a fully-qualified `\Closure(...)`
body, a bare `\Closure` combined in a union (which is NOT a signature body and
stays a union), and a complete signature followed by a trailing token (which
must decline rather than silently drop it). Justify the two equivalent mutants
in the recognizer (the `<`/`<=` boundary that only differs on a bare
`type X = Closure;`, and the unobservable distribution flag on a valid body).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A closure-signature alias reached through another alias's clause leaf
(`type A = Closure(int): bool; type B = A;`) was silently dropped: the
clause loop in expandAliasToDnf read only `->clauses`, which is empty for a
signature body, so the `\Closure` type and its conformance check vanished —
emitting un-loadable / untyped PHP (a whole-slot `type B = A` wrote
`function make(): {`), accepting a non-conforming closure, and dropping a
union arm.

Now a signature reached as the SOLE single-head clause propagates (a
single-head alias to a closure-sig alias IS that signature — it erases to
`\Closure` and its conformance rides through); a signature combined in a
union or intersection is `xphp.alias_unsupported_body`; and a closure-sig
alias used as a bound is `xphp.alias_compound_in_non_slot`.

Found in code review. The runtime fixture now executes a transitive alias
slot, proving the emitted PHP loads.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… full body set

The roadmap Shipped section + timeline, the syntax index row, the comparison
grid + prose, and ADR-0023's scope note still described type-alias bodies as
single-head / union / nullable only (and listed intersection / DNF / closure
as unsupported / roadmapped). Update them to the shipped set: intersection,
DNF, and closure-signature bodies, with the new distribution / scalar codes;
the ADR keeps its historical decision and gains a forward-note that the richer
bodies landed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…y member

A closure signature may now be a MEMBER of a compound alias body — nullable
(`type N = ?Closure(int): bool;`), a union (`type U = Foo | Closure(...);`),
or an intersection (`type X = Foo & Closure(...);`) — erasing to a bare
`\Closure` inside the `?`/`|`/`&` node, byte-identical to the directly-written
slot types. This gives alias bodies parity with direct `Closure(...)` slots.

The AliasBody DNF leaf widens from `TypeRef` to `TypeRef|ClosureSignature`
(the separate whole-body `signature` field is gone — a signature is now just
a leaf). The alias-body reader threads an `allowClosureSig` flag through the
shared bound-expression reader (a defaulted flag, so real generic bounds —
where `Closure(...)` stays rejected — are untouched); the `?`-reader handles
`?Closure(...)`. Resolve / substitute / expand / dedupe / emit all branch on
the leaf kind.

Conformance parity, not more: the validator unwraps a NullableType (so
`?Closure` is enforced) but does not descend into a union/intersection (so a
member is gradual) — exactly matching the direct forms. Because every closure
erases to the same `\Closure`, a body with two `\Closure`-erasing leaves would
emit a `\Closure|\Closure` PHP duplicate-type fatal and is rejected. A scalar
next to a closure in an intersection still rejects on the scalar; a closure-in-
compound alias as a bound / generic-argument / `new` stays compound-in-non-slot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Update the caveats, syntax tour, error catalog, roadmap, comparison grid,
and changelog: a closure signature is now usable as a nullable / union /
intersection member of an alias body (erasing to `\Closure` inside the
`?`/`|`/`&`), with parity to the directly-written slot types. The only
remaining body-shape limit is at most one closure per body — every closure
erases to the same `\Closure`, and PHP forbids a duplicate `\Closure|\Closure`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n coverage

Fold the whole-body / nullable closure recognition into a shared readAliasDnf
helper (the `?`-reader now reuses parseBoundExpr, dropping tryWholeBodyClosureSig
and its edge-guard mutants). Make the bound-expression reader's allowClosureSig
/ source parameters required (they are always passed explicitly, so the defaults
were dead). Mark the two dead-value mutants (the signature `nullable` flag, unread
by conformance; the defensive `\Closure` ltrim) as equivalent, and make
isClosureErasing private.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rename the closure_sig_alias fixture source Handlers.xphp -> Registry.xphp so
the file name matches its primary class, and point the runtime verify at the
emitted Registry.php. The output file is named by source basename, so the
mismatch broke the require.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Aug 7, 2026

Copy link
Copy Markdown

Greptile Summary

The PR expands type-alias bodies to support intersections, DNF expressions, and closure signatures, including generic and compound closure members.

  • Introduces a DNF-based AliasBody representation and compound-member deduplication.
  • Extends alias parsing, substitution, validation, and AST construction.
  • Adds integration fixtures and updates documentation for the richer alias syntax.

Confidence Score: 4/5

The duplicate-member normalization defect should be fixed before merging because valid alias expansion can emit a PHP type that fails to compile.

Alias deduplication preserves case in its identity key, allowing differently cased references to the same PHP class to survive into an intersection or DNF and trigger PHP's duplicate-type error.

Files Needing Attention: src/Transpiler/Monomorphize/AliasBody.php

Important Files Changed

Filename Overview
src/Transpiler/Monomorphize/AliasBody.php Adds the DNF representation and deduplication helpers, but duplicate class-like members are compared case-sensitively.
src/Transpiler/Monomorphize/XphpSourceParser.php Extends alias parsing, recursive expansion, validation, and AST emission for intersections, DNF, and closure signatures.
test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php Adds broad compile/check and runtime coverage for richer alias bodies, though mixed-case duplicate members are not covered.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
    A[Type alias declaration] --> B[Parse body expression]
    B --> C[Normalize to AliasBody DNF]
    C --> D[Resolve and substitute aliases]
    D --> E[Deduplicate clauses and leaves]
    E --> F[Construct PHP union/intersection/Closure AST]
    F --> G[Compile or check emitted program]
Loading

Reviews (1): Last reviewed commit: "test(monomorphize): name the closure fix..." | Re-trigger Greptile

Comment thread src/Transpiler/Monomorphize/AliasBody.php
PHP class-like names are case-insensitive, so a compound alias body that
repeats one class in different casing (`type X = Foo & foo`) named the same
class in both leaves — yet the dedup key used the case-preserving canonical
name, so both survived and the slot emitted `\App\Foo&\App\foo`, which PHP
rejects at load as a duplicate-type fatal. The same held for a union arm
(`Foo | foo` → `\App\Foo|\App\foo`).

Lowercase the leaf key so it matches PHP's own case-insensitive class
resolution; both the intersection (`dedupeLeaves`) and union (`dedupeClauses`)
paths route through it, so one change covers both. Genuinely distinct members
(`A & B`) are untouched. Aligns the key with `isClosureErasing`, which already
lowercases the class name.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
greptile-apps[bot]

This comment was marked as off-topic.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant