CLI: Interop Contract

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.

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.

Why this contract exists

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.

Contract version

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).

Warning

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.

Synthetic dispatch naming

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

Operators

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.

GeneratedNames (generics & property hooks)

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.

Extensions

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.

Type erasure

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).

Runtime entry points

FQNs the contract covers. Packages are Composer names under runtime/packages/.

Emitter RequirePackage today

The 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 sites

Those packages are what the build action can auto-wire into a project’s composer.json from emit.

Required contract surface (all packages)

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.

Lowering protocols (index)

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.

Compatibility policy

  • Additive changes (new optional runtime helpers the emitter does not yet call, new convertible targets that do not rename existing methods) may ship without bumping interopContractVersion. A minor bump is optional documentation hygiene.
  • Breaking changes — any rename or signature change of an emitted call, synthetic method, or required FQN — require bumping interopContractVersion on the compiler (InteropContract.CurrentVersion) and on every affected runtime package’s extra.tyhp.interopContractVersion.
  • PHP matrix — which PHP minor a release supports is coordinated with the 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.

Machine-checkable surface

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.

Tip

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.