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. This page 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 when needed. Foreign compiled-library sites use \Tyhp\Generic::bind. #[\Tyhp\GenericRuntime] is runtime-visible on emitted generics. #[\Tyhp\EraseGeneric] is compile-only. |
Generics |
| Type aliases | Hints expand to the underlying PHP type. Source .tyhp aliases exist at runtime as namespace functions or class static methods returning \Tyhp\Type (UserId(), Optional(\Tyhp\Type::int()), UserService::NameType()), not as PHP types. Tyhpdef aliases stay type-only (typeof inlines the body) unless they stamp aliasFactory. |
Type Aliases |
| Type guards / narrowing | Compile-time only; is / guard checks lower to ordinary PHP boolean tests (instanceof, \is_string($x) when #[\Tyhp\NativeTypeTest] is on that guard, otherwise \Tyhp\Type::is). Object-shape aliases always use \Tyhp\Type::is with a Type::objectShape(...) descriptor (existence-only matching). |
Type Narrowing and Guards |
#[\Tyhp\NativeTypeTest] |
Compile-only (#[\Tyhp\NoEmit]). Never appears in emitted PHP. Picks which type-guard function or concrete static method is the native $x is T lowering. |
Functions in Tyhpdef |
#[\Tyhp\EraseGeneric] |
Compile-only (#[\Tyhp\NoEmit]). Opts a property, promoted constructor parameter, or whole class out of Mechanism C property tracking. |
Generics |
#[\Tyhp\GenericRuntime] |
Runtime-visible stamp on every emitted generic class/method/function (erased, layouts, optional factory/binder/aliasFactory). Not NoEmit. |
Generics |
| 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 a pack: value-position Rest<T> ...$args unpacks at the call; inside a callable-shape parameter list it auto-splices. __CallableParametersSlice<T, TStart, TMin> selects a parameter or a tail of parameters. Rest/Slice erase 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 / zip \array_map tyhpdefs use these utilities; emit is still the PHP builtins (no new \Tyhp\* types, no contract bump) |
New Types |
internal |
Stripped; members emit public, top-level unprefixed. Omitted from package.tyhpdef. |
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, Type::objectShape, …) |
\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\Generic |
Generic::bind for PHP and foreign compiled-library sites |
\Tyhp\GenericRuntime |
Runtime-visible stamp on emitted generic declarations |
\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: when a call argument targets PropertyPath<callable(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: when a call argument targets Expression<callable(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 takes one type argument: the callable shape (Expression<callable(User): string>; Expression<callable(): R> for zero parameters). That shape is a checker/type-system convention; 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/; 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 |
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<callable(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/ |
A thin index suite lives under tests/conformance/story15/ (operators, extensions, generics erasure, structs, disposables, async, expression trees). Fuller emit coverage remains in tests/conformance/story11/ and tests/conformance/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.tyhpdef/php / tyhpdef/php-ext-* packages and the PHP-encoded compiler MAJOR in 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 (recompile runtime/packages/*/tyhp_src and diff against committed PHP) consumes this list once the self-host allowlist is green. Documenting or version-stamping the contract does not wait on that milestone.
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.