Note
Emitter dictates runtime. The emitter design owns the ABI. Runtime packages implement the shapes the emitter emits; they do not invent alternate public APIs that the compiler is expected to chase. See also CONVENTIONS.md §8.
Tier 1 · Story 15Complete
The Tyhp ↔ PHP interop contract is the written ABI between the Tyhp compiler (emitter) and the Tyhp runtime packages (tyhp/core, tyhp/async, tyhp/decimal, tyhp/lambda). Compiled Tyhp depends on concrete PHP shapes — class names, method names, and call patterns. This page is the user-facing index for that boundary.
Emitter dictates runtime. The emitter design owns the ABI. Runtime packages implement the shapes the emitter emits; they do not invent alternate public APIs that the compiler is expected to chase. See also CONVENTIONS.md §8.
Without an explicit contract, the coupling is implicit: emitter transformers call \Tyhp\… by exact FQN and signature, and the runtime must match. When either side changes silently, builds break late. Story 15 writes the contract down, stamps a version on both sides, and keeps a machine-checkable surface so drift is caught early.
Each runtime package stamps the contract it implements in Composer metadata:
{
"extra": {
"tyhp": {
"interopContractVersion": 1
}
}
}
The compiler constant is Tyhp.TyhpLang.Interop.InteropContract.CurrentVersion (currently 1). When a required runtime package’s stamp does not match the compiler’s current version, the build fails with diagnostic TYHP5018 (error).
interopContractVersion is not the same as the compiler’s MAJOR.MINOR.PATCH (see VERSIONING.md) or a package’s Composer semver. A breaking change to an emitted name or signature bumps the interop contract on both the compiler and the runtime packages.
Canonical spellings live in compiler helpers — do not invent parallel schemes. Ownership:
| Kind | Owner |
|------|--------|
| Operator overload methods | OperatorMethodNameGenerator (Tyhp/TyhpLang/Emitter/OperatorMethodNameGenerator.cs) |
| Generics / property-hook polyfill names | GeneratedNames (Tyhp/TyhpLang/GeneratedNames.cs) |
| Project conventions pointer | CONVENTIONS.md §8 |
Every overloadable operator maps to a single deterministic method name (no type suffixes, no _N collision numbers). Multiple forms of the same operator collapse into one method that dispatches on operand types.
Binary / comparison
| Operator | Method |
|----------|--------|
| + | __add |
| - (binary) | __subtract |
| * | __multiply |
| / | __divide |
| % | __mod |
| ** | __pow |
| & | __bwAnd |
| \| | __bwOr |
| ^ | __bwXor |
| << | __bwSL |
| >> | __bwSR |
| . (concat) | __concat |
| < | __isLessThan |
| <= | __isLessThanOrEqual |
| > | __isGreaterThan |
| >= | __isGreaterThanOrEqual |
| == | __isEqual |
| != | __isNotEqual |
| === | __isExact |
| !== | __isNotExact |
| <=> | __compare |
Unary / word
| Operator | Method |
|----------|--------|
| unary + | __asNumeric |
| unary - | __negate |
| ++ | __increment |
| -- | __decrement |
| ~ | __bwNot |
| ! | __not |
| empty | __isEmpty |
Convert
| Form | Method |
|------|--------|
| convert-from (static factory) | __from |
| convert-to string | __toString |
| convert-to bool | __toBool |
| convert-to int | __toInt |
| convert-to float | __toFloat |
| convert-to decimal | __toDecimal |
| convert-to other type T | __to{FormattedSegment} |
Binary/unary/empty overloads emit as static methods. Convert-to emits as an instance method so it can satisfy \Stringable and \Tyhp\Contracts\*Convertible. Call sites rewrite to ClassName::__add($l, $r) (and peers). Feature detail: Operator Overloads.
| Pattern | Example / spelling |
|---------|-------------------|
| Generic variant / bag suffix | __tyhpGeneric |
| Generic init hook | __initGenerics__tyhpGeneric |
| Generic factory | new_<MangledFqn>__tyhpGeneric |
| Polyfill get hook | __get_<prop>__tyhpPropertyHook |
| Polyfill set hook | __set_<prop>__tyhpPropertyHook |
| Polyfill init hook | __initPropertyHooks__tyhpPropertyHook |
| Extension $this receiver rename | $this_ (GeneratedNames.ExtensionReceiverThisAlias) |
These identifiers (except the extension-receiver rename, which is emit-only) are reserved by the checker so user code cannot collide with generated symbols.
Extension methods keep their declared method name. Call sites rewrite instance-style calls into static dispatch with the receiver first:
ExtensionClass::method($receiver, /* …args */);
When the author names the receiver $this (the documented spelling), emit renames that parameter to $this_ in the static method signature and rewrites body references (including nested closures) accordingly — PHP rejects a parameter literally named $this. Other receiver names emit unchanged.
Null-safe extension calls short-circuit with a temporary receiver binding. See Extensions.
Tyhp features that exist for type-checking often leave no (or a reduced) PHP footprint:
| Feature | At runtime | See |
|---------|------------|-----|
| Generics | Type parameters erased from signatures; tracked objects keep a GenericObject bag ($__tyhpGeneric) when the emitter needs runtime checks | Generics |
| Type aliases | Fully erased; usages spell the underlying type | Type Aliases |
| Type guards / narrowing | Compile-time only; is / guard checks lower to ordinary PHP boolean tests (instanceof, is_*, etc.) | Type Narrowing and Guards |
| Structs | Declarations erased; values are associative arrays | Structs |
| Callable signature utilities | Compile-time only; __CallableReturnType<T> erases to the callable's return type (or mixed while T is still unbound); __CallableParametersStruct<T> / __CallableParametersTuple<T> erase to array (struct-as-array). Tuple bags use int keys 0..n-1 ($_1, $_2, … aliases, same shape family as CallableArgs*). __CallableParametersRest<T> is the rest-unpack marker for Rest<T> ...$args (TypeScript ...args: Parameters<T>); it erases to mixed so PHP does not demand each unpacked argument be an array. Defaulted parameters are optional struct fields at check time; optionality does not survive erasure. ExtStandard \call_user_func / \call_user_func_array tyhpdefs use these utilities; emit is still the PHP builtins (no new \Tyhp\* types, no contract bump) | New Types |
| internal | Not in this alpha (Story 25). Planned: compile-time visibility only; would emit as ordinary PHP visibility | internal modifier |
What survives is whatever the emitter explicitly lowers into calls on the runtime surface below (operators, disposables, async, property-hook polyfills, generic bags, with, PropertyPath / expression trees, and so on).
FQNs the contract covers. Packages are Composer names under runtime/packages/.
RequirePackage todayThe emitter currently records required Composer packages via EmitContext.RequirePackage for:
tyhp/core — generics, Type/NamedType, property-accessor polyfill, ObjectHelper, convertible contracts, operator overload exceptions, …tyhp/async — Promise, await, async wrappers, async disposal paths, …tyhp/lambda — PropertyPath and full Expression trees construction at call sitesThose packages are what the build action can auto-wire into a project’s composer.json from emit.
Even when the emitter does not yet RequirePackage a package for every call site, the following FQNs are part of the written interop surface (direct calls, future emit, and self-host):
tyhp/core| FQN | Role |
|-----|------|
| \Tyhp\Type | Runtime type values / checks (Type::check, Type::is, …) |
| \Tyhp\NamedType | Named type arguments in the generic bag |
| \Tyhp\GenericObject | Per-instance generic argument bag |
| \Tyhp\PropertyAccessor | Property-hook polyfill registrations |
| \Tyhp\PropertyAccessorObject | Host for registered accessors ($__tyhpPropertyHook) |
| \Tyhp\ObjectHelper | Object-form with / clone-with lowering |
| \Tyhp\Contracts\IsDisposable | Sync disposable protocol |
| \Tyhp\Concerns\HasGenerics | Trait wiring the generic bag |
| \Tyhp\Concerns\UsesPropertyAccessors / HasPropertyAccessors / HandlesGet / HandlesSet / HandlesIsset / HandlesUnset / BootsTraits | Property-accessor polyfill concerns |
| \Tyhp\Contracts\{Convertible,StringConvertible,BoolConvertible,IntConvertible,FloatConvertible} | Convert-to interfaces |
| \Tyhp\Exceptions\InvalidParametersForOperatorOverloadException | Operator dispatch miss |
| \Tyhp\Exceptions\AggregateException | Multi-error disposal |
| \Tyhp\Exceptions\{InvalidTypeException,IncompatibleTypeException,PropertyNotFoundException} | Key type/property failures |
tyhp/decimal| FQN | Role |
|-----|------|
| \Tyhp\Decimal | Decimal value type |
| \Tyhp\Contracts\DecimalConvertible | Convert-to decimal |
Contract surface for decimal operators / convert targets and direct use. The emitter does not currently list tyhp/decimal via RequirePackage for every decimal path; projects that use decimal still depend on the package.
tyhp/async| FQN | Role |
|-----|------|
| \Tyhp\Promise | Async wrappers, _await, _async, run |
| \Tyhp\EventLoop | Fiber / timer loop |
| \Tyhp\CancellationToken / \Tyhp\CancellationTokenSource | Cancellation |
| \Tyhp\DisposableScope | := disposable scopes |
| \Tyhp\Contracts\AsyncIsDisposable | Async disposal |
tyhp/lambda| FQN | Role |
|-----|------|
| \Tyhp\Expression | Expression-tree root — Phase 2 emit target; runtime base for PropertyPath |
| \Tyhp\Expression\ExpressionNode (and Expression\* node types) | Tree nodes — Phase 2 emit builds these directly |
| \Tyhp\PropertyPath | Property-path expressions — Phase 1 emit target |
Phase 1 emit ABI (Story 16): when a call argument targets PropertyPath<T, R> and the argument is an inline property-chain fn, the emitter lowers to:
new \Tyhp\PropertyPath(
\SourceType::class, // or a builtin type string such as 'string'
'resultTypeString',
['segment', /* … */],
fn (/* … */) => /* original chain */
);
Constructor shape (required): (string $sourceType, string $resultType, array $path, \Closure $callable, bool $allowEmpty = false, array $nullSafeFlags = []). Call sites omit $allowEmpty.
A chain containing ?-> additionally passes the per-segment flags by name, so the runtime builds NullSafeAccessExpression nodes instead of PropertyAccessExpression for those segments:
new \Tyhp\PropertyPath(
\User::class,
'?string',
['address', 'city'],
fn (\User $u): ?string => $u?->address?->city,
nullSafeFlags: [true, true],
);
Phase 2 emit ABI (Story 16): when a call argument targets Expression<T, R> and the argument is an inline arrow fn, the emitter lowers to a root Expression plus nested node constructors (additive — no contract version bump):
new \Tyhp\Expression(
body: new \Tyhp\Expression\BinaryExpression(
new \Tyhp\Expression\PropertyAccessExpression(
new \Tyhp\Expression\ParameterExpression('u', \User::class, 0),
'age',
'int'
),
'>',
new \Tyhp\Expression\ConstantExpression(18, 'int'),
'bool'
),
parameters: [
new \Tyhp\Expression\ParameterExpression('u', \User::class, 0),
],
callable: fn (\User $u) => $u->age > 18,
returnType: 'bool'
);
Supported nested FQNs include ParameterExpression, PropertyAccessExpression, NullSafeAccessExpression, MethodCallExpression, StaticMethodCallExpression, BinaryExpression, UnaryExpression, ConstantExpression, TernaryExpression, CoalesceExpression, ArrayAccessExpression, CastExpression, NewExpression, and InstanceofExpression (Phase 3 — $x is T / $x instanceof T). Captured outer variables emit as ConstantExpression($var, $type).
Expression<TArgs…, TReturn> follows the callable return-last convention: Expression<R> is zero-parameter, Expression<T, R> is one-parameter, Expression<T, T, int> is a two-parameter comparator. That arity is a checker/type-system convention; the runtime class remains Expression<TSource, TReturn> and emit still constructs new \Tyhp\Expression(...). No contract version bump.
nameof(fn ($x) => $x->a->b) is a compile-time fold to the last property segment (a string literal). It does not emit PropertyPath / Expression construction.
ExpressionSerializer::toJson / equals and Expression::equals are runtime library APIs (not emitter call patterns). User code that calls them depends on tyhp/lambda the same way any other package type does.
When a PropertyPath / Expression value is passed where \Closure is expected, emit extracts the stored closure as $expr->callable (public readonly property on \Tyhp\Expression). Where callable is expected, the object is passed through (__invoke).
Feature detail: Parsable Lambdas.
Brief map from Tyhp construct → PHP runtime pattern. Goldens live under the conformance suite; prefer those over re-deriving internals from this page.
| Construct | Lowering sketch | Goldens / docs |
|-----------|-----------------|----------------|
| Disposables := | \Tyhp\DisposableScope::create() + using / try-finally fallback | tests/conformance/story11/disposables/; Disposables |
| Async / await | \Tyhp\Promise::_async / _await / run; return type \Tyhp\Promise | tests/conformance/story11/async/; Async and Await |
| with / clone-with | \Tyhp\ObjectHelper::with(…) (or native PHP 8.5 clone(…) when targeted) | tests/conformance/story11/with/; Story 14.5 clone suites; with keyword |
| Operators | Static/__to* calls via __add, … | tests/conformance/story11/operator-overloads/; Operator Overloads |
| Generics | Erasure + optional GenericObject bag / __initGenerics__tyhpGeneric / factories | tests/conformance/story11/generics/; Generics |
| Property hooks (PHP < 8.4) | Strip native hooks; UsesPropertyAccessors + PropertyAccessor registration; __get_/__set_*__tyhpPropertyHook | Story 14.5 hook suites (native ≥ 8.4/8.5); polyfill paths in emitter |
| Expression trees / PropertyPath | Phase 1: new \Tyhp\PropertyPath(…) (plus nullSafeFlags: for ?-> chains); Phase 2: new \Tyhp\Expression(…) + nested \Tyhp\Expression\* nodes; Phase 3: InstanceofExpression for is/instanceof, multi-parameter Expression<TArgs…, TReturn> arity (still new \Tyhp\Expression); \Closure sites → $path->callable | tests/Tyhp.Tests/Emitter/PropertyPathEmitterTests.cs, ExpressionEmitterTests.cs; Parsable Lambdas |
| PHP 8.5 surface / lower targets | Pipe, (void), clone(…), exit/die, attributes | tests/conformance/story14_5/ |
Story 15 owns a thin index suite under tests/conformance/story15/ (operators, extensions, generics erasure, structs, disposables, async, expression trees). Fuller emit coverage remains in story11 / story14_5 — see the conformance README.
interopContractVersion. A minor bump is optional documentation hygiene.interopContractVersion on the compiler (InteropContract.CurrentVersion) and on every affected runtime package’s extra.tyhp.interopContractVersion.VERSIONING.md. Contract bumps and MAJOR bumps are independent axes; both may move in the same release when an ABI and a PHP target change together.InteropContractSurface enumerates emitter-required runtime symbols (the contract surface). Conformance tests assert that each symbol is declared — with the documented namespace and keyword — in the owning package’s committed package.tyhpdef. The tyhpdef is the checked source of truth because emitted src/ PHP is regenerated (and dist/ is not committed).
Runtime self-host (Story 07: recompile runtime/packages/*/tyhp_src and diff against committed PHP) consumes this list once the Story 07 allowlist is green. Until then, Story 15 treats self-host as gated — the surface list is ready for that milestone; it does not block documenting or version-stamping the contract.
When changing emit call patterns, update OperatorMethodNameGenerator / GeneratedNames, the runtime packages, InteropContractSurface, this page, and bump interopContractVersion if the change is breaking — in that order of truth: code helpers → runtime → surface enum → docs → version stamp.