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 |
#[\Tyhp\PropertyType] |
Runtime-visible stamp on a property or promoted constructor parameter whose PHP hint loses its Tyhp type (type arguments, struct types, source type aliases). Its one argument is the full type in the \Tyhp\Type::parse() format ('array<App\\Post>'); \Tyhp\Type::fromProperty() reads it. Types that mention a class type parameter are not stamped. |
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 identified by their Composer names (tyhp/core, tyhp/async, tyhp/lambda, …).
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 and future emit):
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\PropertyType |
Runtime-visible declared-type stamp on properties and promoted constructor parameters |
\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; runtime base for PropertyPath |
\Tyhp\Expr |
Fluent tree builder — emit target for Expression arguments |
\Tyhp\Expression\ExpressionNode (and Expression\* node types) |
Tree nodes, built by \Tyhp\Expr |
\Tyhp\PropertyPath |
Property-path expressions — emit target through PropertyPath::on |
PropertyPath emit ABI: when a call argument targets PropertyPath<callable(T): R> and the argument is an inline property-chain fn, the emitter lowers to a chain with one property step per segment, ended by backedBy with the original fn:
\Tyhp\PropertyPath::on(\SourceType::class) // or a builtin type string such as 'string'
->segment
/* … */
->backedBy(fn (/* … */): ResultType => /* original chain */);
A segment read through ?-> is preceded by nullSafe(), so the runtime builds a NullSafeAccessExpression for that hop instead of a PropertyAccessExpression:
\Tyhp\PropertyPath::on(\User::class)->billing->nullSafe()->city->backedBy(fn(\User $u): ?string => $u->billing?->city);
Expression emit ABI: when a call argument targets Expression<callable(T): R> and the argument is an inline arrow fn, the emitter lowers to a \Tyhp\Expr chain ended by backedBy with the original fn, which returns the \Tyhp\Expression:
\Tyhp\Expr::param('u', \User::class)->age->gt(18)->backedBy(fn(\User $u): bool => $u->age > 18);
The chain uses these builder calls:
Expr::param(name, type, index) starts at a parameter; index is passed when the fn has several parameters.->age), or prop('name') when the name matches a builder method. nullSafe() precedes a ?-> hop.call(name, ...args), staticCall(Class::class, name, ...args), and make(Class::class, ...args) build method calls, static calls, and new.gt, gte, lt, lte, eq (===), neq (!==), looseEq (==), looseNeq (!=), spaceship, plus, minus, times, dividedBy, mod, pow, concat, bitAnd, bitOr, bitXor, shiftLeft, shiftRight, not, neg, pos, bitNot, and, or, xor, coalesce, then / else, is, cast, and at (array access).fn is Expr::closure([Expr::param(...), …], <body chain>, 'returnType'), and an array literal is Expr::arrayOf([values], [keys]).->gt($minAge), ->value(User::FLAG)), so their values are read when the chain runs.A nested fn passed to a method's callable, Expression, or PropertyPath parameter becomes an Expr::closure node:
\Tyhp\Expr::param('u', \User::class)->tags->call('any', \Tyhp\Expr::closure([\Tyhp\Expr::param('t', \Tag::class)], \Tyhp\Expr::param('t', \Tag::class)->label->eq('vip'), 'bool'))->backedBy(fn(\User $u): bool => $u->tags->any(fn(\Tag $t): bool => $t->label === 'vip'));
Parameter types use the \Tyhp\Type::parse() format. A parameter whose type has type arguments or is a struct is passed as a string ('App\Table<App\Blog>', 'struct App\Address') instead of Fqn::class; a property node's type is the declared property type (\Tyhp\Type::fromProperty()). The tree's return type comes from the backing closure's return 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; the emitted chain is the same for every shape.
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.
PHP that calls compiled Tyhp, and PHPStan or Psalm run on that PHP, can rely on docblock tags wherever the native declaration drops a type Tyhp checked. The tags are on in every build profile and do not depend on output.comments.
emit.docblocks defaults to "lossy-only": a tag is written only when it says more than the PHP hint (array<int, User> becomes native array plus @param array<int, \App\User>). "all" tags every signature type. "off" writes none of the generated tags. emit.docblockDialect ("both" by default, or "phpstan", "psalm", "phpdoc") is parsed and validated and does not change the text. Shared tags are unprefixed.
What a consumer can rely on, when the checker had the type: @param, @param-out for a by-ref parameter, @return, @var, @template (with of, variance, and a default when the parameter has them), @extends, @implements, @use, and @internal. @throws is not generated; an author @throws is kept. Symbol names in generated tags are fully qualified, and a use … as alias is the real name. self, parent, and static stay relative. A struct is array{…} with optional keys as key?:. A callable is callable(int): string. class-string is used for a class, interface, or enum name.
An author docblock's summary stays above the tags. @tyhp emitter directives and /@@ … @@/ blocks pass through. An author analysis tag wins per key; the generator fills gaps. output.comments: false drops the prose and keeps the directives and the analysis tags.
The compiler does not run PHPStan or Psalm. The full spelling and merge rules are on Emitted Docblocks.
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 | \Tyhp\PropertyPath::on(…)->segment…->backedBy($fn) (nullSafe() before ?-> hops); \Tyhp\Expr::param(…)->…->backedBy($fn) for Expression (nested fn → Expr::closure); \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).
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.