Tip
Use extensions to add utility methods to types you don't own — they keep your code clean without subclassing or wrapper patterns.
Tier 0 · Story 03Complete
Extensions in Tyhp allow you to add new methods and operator overloads to existing types without modifying their source code. This is useful for adding functionality to classes you don't own, imported PHP types, or even scalar types like string, int, and array. The compiler always rewrites Tyhp call sites. A member's form decides whether PHP also keeps a method on the extension class.
The target is written once on the extension. $this inside each member is that target. Callers do not pass $this; call sites look like instance methods ($text->toCamelCase()). Member names follow PHP method naming, so names such as match, and, and or are allowed.
A header target applies to every member:
<?tyhp
extension StringExtensions extends string {
function toCamelCase(): string {
$parts = \explode('_', $this);
return \lcfirst(\implode('', \array_map('\ucfirst', $parts)));
}
function toSnakeCase(): string {
return \strtolower(\preg_replace('/[A-Z]/', '_$0', $this));
}
function truncate(int $maxLength, string $suffix = '...'): string {
if (\strlen($this) <= $maxLength) {
return $this;
}
return \substr($this, 0, $maxLength - \strlen($suffix)) . $suffix;
}
}
// Using extension methods — called as if they were instance methods
string $text = 'hello_world';
echo $text->toCamelCase(); // outputs: helloWorld
echo $text->toSnakeCase(); // outputs: hello_world
A mixed extension omits the header target. Each nested group names its own type. Groups are one level: a group contains members, not further groups. A header extends together with a nested group is TYHP4362. A member beside those groups, outside any group, is TYHP4363. Members with no target at all are TYHP4147. An empty extension is TYHP4172.
<?tyhp
extension NumericHelpers {
extends int {
fn abs(): int => \abs($this);
}
extends float {
fn abs(): float => \abs($this);
}
extends<T> \App\MyClass<T> {
function id(): T { return $this->id; }
}
extends<TRight> \App\Pair<string, TRight> {
function right(): TRight { return $this->right; }
}
}
Generics on the extension name are the same kind of binder as extends<T> on a group:
<?tyhp
extension MyClassOps<T> extends \App\MyClass<T> {
function id(): T { return $this->id; }
}
extension Ops<T extends int|string> extends \App\MyClass<T> {
function id(): T { return $this->id; }
}
The extends inside <T extends int|string> is the type-parameter constraint. The extends after the name, or the extends that opens a group, is the target.
Those type parameters live on the extension symbol, or on the group for extends<T>. They are in scope for the target and for the members in that block. They are not copied onto each method. A method's type parameters are only the ones written on that method (function mapTo<R>(callable(T): R $fn): array<R>). Reusing an in-scope name on the method is TYHP4366. A type parameter that is never referenced is TYHP4365.
Writing the target on the member (extends Type $this, or <Type> after the operator token) is TYHP4361. Adaptation clauses in use extension still qualify an operator by target: E::operator +<Money> hide.
A target is one class, interface, enum, type alias, builtin, or generic application, including object, callable, and struct. ?T applies to a receiver of type T and of type ?T. Inside the member, $this is ?T; a use as T needs a null check (!== null narrows it).
A union is a legal target when every member of the union is itself a legal target. The member applies when every type the receiver might be is a member of that union. $this in the member is the whole union. extends string|int applies to string, to int, and to string|int. It does not apply to string|int|bool or to ?string. A union that includes null applies to every non-empty combination of its members (so extends null|string applies to string and to ?string) and does not apply to a receiver that is only null.
TYHP4364 reports an intersection, void, never, mixed, a type parameter, an object { … } shape, _, or a union that contains one of those. An alias is read through to its body: an alias of a legal union is a legal target.
Two groups in the same extension that can both apply to one receiver and declare the same method or the same operator are TYHP4367. extends string and extends string|int both apply to string, so the same member on both is an overlap. Different member names on overlapping targets are fine. Targets that do not overlap, such as Money and \DateTime, may share a method name. The same member on two separate extensions is an activation conflict (hide / insteadof on use extension), not TYHP4367.
A target that does not resolve to a class or built-in is TYHP3016. When that block declares an operator and the target is void, never, null, mixed, resource, true, or false, the diagnostic is TYHP3025.
$this$this is the block target. It is not a source parameter and it is not a named argument. &$this, with no type written on it, marks the member by-reference. Use it when the body writes $this: assignment, compound assignment, ++ / --, or passing $this to a by-reference parameter. Writing $this without &$this is TYHP4369. Declaring &$this when the body never writes $this is TYHP4370.
Calling a method on an object $this, or assigning one of its properties ($this->amount = 1), does not write $this. The handle is shared, so the caller sees the mutation without &. On a scalar, array, or struct target, assigning through $this does write it ($this->field = …, $this[$k] = …, $this[] = …), because those values are copied into the parameter.
The PHP parameter is $this_. It is &$this_ only when the member is annotated &$this. A by-value member emits $this_ with no &, including when the target is a class, interface, or enum. A by-reference call needs a referenceable receiver (TYHP4180); a literal such as 'hello'->sort() is not one.
<?tyhp
extension IntOps extends int {
function label(): string { return (string)$this; }
function bump(&$this): void { $this = $this + 1; }
}
selfself, new self(), and self:: inside a member are the block target, including a scalar. extends int emits int (for example int::class and a return type of int), not the extension class. Name the extension class by writing its name (MoneyOps). static:: and parent:: in an extension member are TYHP4368. An anonymous class nested in the member keeps its own self and static.
Hover on the extension name shows extension Name extends Target, with generics on the name when they were written there (extension StringOps<T> extends string). A header with no target shows extension Name. Hover on a nested group shows extends Target or extends<T> Target. A method signature lists the parameters you wrote; $this is not one of them.
A Tyhp extension { } member has three forms. The form is the contract for what PHP keeps:
| Form | PHP backer method | Tyhp call sites |
|---|---|---|
Short fn … => expr; |
Omitted — Tyhp-only | Splice expr |
Brace body with a single return expr; |
Emitted — PHP can call it on the backer | Splice expr |
| Brace body with multiple statements | Emitted | Call the backer method |
Choose a form by what you need:
| Goal | Write |
|---|---|
| Tyhp-only, no PHP method | fn … => expr; |
| Spliced in Tyhp and callable from PHP | function … { return expr; } |
| Multi-statement logic | function … { … } — a real PHP method that Tyhp calls |
The single-return brace body is also the way to keep a PHP method when a call site cannot be spliced faithfully. A short => member is erased, so that situation is error TYHP4181. The brace form emits a method the call site can fall back to.
A parameter the member writes must be declared &, and only those parameters are by reference. See By-reference parameters.
Members may carry attributes, the same way class methods and tyhpdef extension members do:
<?tyhp
extension ArrayHelpers extends array {
#[\Tyhp\Optimize\Pure]
function sorted(): array {
array $copy = \array_values($this);
\sort($copy);
return $copy;
}
}
#[\Tyhp\Optimize\Inline] is still an error (TYHP4176). Form decides splicing.
Multi-statement members compile to static methods on the extension class. The object the method is called on becomes the first argument. Short => members are spliced into the call site and do not appear on the class. A single-return brace member is spliced in Tyhp and still emitted as a PHP method.
<?tyhp
extension StringHelpers extends string {
function complexStringProcess(): string {
string $finalString = \trim($this);
return $finalString;
}
fn shortProcess(): string => ' ' . $this;
function simpleProcess(): string {
return $this . ' ';
}
}
string $a = $name->shortProcess();
string $b = $name->simpleProcess();
string $c = $name->complexStringProcess();
<?php
class StringHelpers
{
public static function complexStringProcess(string $this_): string
{
$finalString = \trim($this_);
return $finalString;
}
public static function simpleProcess(string $this_): string
{
return $this_ . ' ';
}
}
$a = (' ' . $name);
$b = ($name . ' ');
$c = StringHelpers::complexStringProcess($name);
shortProcess has no PHP method — PHP cannot call it. simpleProcess does, even though Tyhp splices the expression instead of calling it.
If every member is short =>, the compiler emits no PHP backer class.
The earlier StringExtensions example uses multi-statement members, so every call becomes a static method call:
$text = 'hello_world';
echo StringExtensions::toCamelCase($text);
echo StringExtensions::toSnakeCase($text);
A spliced member substitutes the caller's argument where the parameter stood, which behaves like passing by reference. Three rules keep that identical to a real call.
A written parameter must be declared &, and only those parameters are. If the expression assigns to a parameter, compound-assigns, increments or decrements it in either position (++$v or $v++), or passes it to a callee's & parameter, that parameter must be declared &. Declaring & on a parameter the expression never writes is equally an error (TYHP4174). The receiver uses the &$this annotation from Declaring an Extension: $a->sort() with function sort(&$this) can splice to \sort($a, …). Writing $this without that annotation is TYHP4369.
++ counts as a write in either position. A non-mutating increment is written $v + 1.
An argument for a & parameter must be something PHP can reference (TYHP4180). The receiver of a member annotated &$this is that argument. These shapes are not:
readonly properties{ get; } hook, __get, or offsetGetA tyhpdef { &get; } is referenceable, as are &__get and &offsetGet.
A parameter used more than once is evaluated once. The compiler introduces a local for the first evaluation and reuses it, so $n->doubled() with fn doubled(): int => $this + $this does not evaluate the receiver twice.
<?tyhp
extension Counters extends int {
fn bump(&$this): int => ++$this;
}
Extensions can add methods to any class type, including third-party classes you don't own.
<?tyhp
extension DateTimeExtensions extends \DateTime {
function isWeekend(): bool {
$day = (int)$this->format('N');
return $day >= 6;
}
function isBusinessHours(): bool {
$hour = (int)$this->format('G');
return $hour >= 9 && $hour < 17 && !$this->isWeekend();
}
}
$now = new \DateTime();
if ($now->isBusinessHours()) {
echo 'Office is open';
}
<?php
$now = new \DateTime();
if (DateTimeExtensions::isBusinessHours($now)) {
echo 'Office is open';
}
Put the target's type parameters on the extension or on the group. Add further type parameters on the method when that method needs them. Header and group parameters stay on that symbol; the method lists only its own.
<?tyhp
extension IterableExtensions<T> extends iterable<T> {
function firstOrNull(callable(T): bool $predicate): ?T {
foreach ($this as $item) {
if ($predicate($item)) {
return $item;
}
}
return null;
}
}
extension ArrayExtensions<T extends int|float> extends array<T> {
function max(): T {
if (empty($this)) {
throw new \RuntimeException('Array is empty');
}
return \max($this);
}
function mapTo<R>(callable(T): R $fn): array<R> {
return \array_map($fn, $this);
}
}
array<int> $numbers = [1, 2, 3];
echo $numbers->max(); // outputs: 3
array<string> $upper = $numbers->mapTo(fn(int $n): string => (string)$n);
<?php
$numbers = [1, 2, 3];
echo ArrayExtensions::max($numbers);
$upper = ArrayExtensions::mapTo($numbers, fn(int $n): string => (string)$n);
Operators list every operand. self in the signature is the block target. abstract and final are not allowed on extension operators, nor on tyhpdef extension fn or extension operator members.
<?tyhp
extension StringOperators extends string {
operator * (self $left, int $right): string
{
return \str_repeat($left, $right);
}
}
string $line = '-' * 40; // 40 dashes via StringOperators
self in the parameter list is the header or group target. An operand of another type is written as that type, so int + Money is operator + (int $left, self $right).
<?tyhp
extension MoneyOperators extends Money {
operator + (self $left, self $right): self {
return $left->plus($right);
}
operator == (self $left, self $right): bool {
return $left->isEqualTo($right);
}
}
When tyhp build emits a library’s public API, a standalone Tyhp extension StringHelpers { … } becomes tyhpdef pieces that follow the member's form:
class StringHelpers as StringHelpers__tyhpExtensionBackerextension StringHelpers { … } whose members are thin fn / operator mappingsThe mapping copies the expression for a short => member and for a single-return brace member. A multi-statement member maps onto the backer (=> StringHelpers__tyhpExtensionBacker::complexStringProcess($this)). An all-=> extension gets no backer stub class at all.
The suffix __tyhpExtensionBacker is Tyhp-only; it never appears in emitted PHP. A user type with that Tyhp name is TYHP4171.
tyhp/core uses the same pattern for multi-statement scalar methods: Tyhp bodies live in tyhp_src/ (compiled as package source, not listed in include), and the tyhpdef declares class StringExtensions as StringExtensions__tyhpExtensionBacker plus thin => mappings. All-=> catalogs (IntExtensions, FloatExtensions, BoolExtensions) have no backer class.
Generated package.tyhpdef copies in-package global use / global use extension that the library authored. File-level use extension is not copied. Loading a package activates extension { } blocks for consumers only when that tyhpdef contains global use extension.
Same-file Tyhp extension { } stays in scope in that file. Class-body tyhpdef use extension on a type still auto-activates that type’s attached surface for consumers, minus hidden members.
See Extensions in Tyhpdef for the tyhpdef extension Name { } syntax (the same header and nested groups; short fn / operator with => only; empty {} is an error).
For the full tyhpdef reference (standalone extension Name { }, thin class-body mappings, native vs mapped operators, and use extension on a class), see Extensions in Tyhpdef.
Inside a tyhpdef class body you can declare extension fn and extension operator members as thin => mappings. The compiler treats them as a synthetic extension for that class: $this in an extension fn and self in an inline extension operator resolve to the enclosing tyhpdef class. These members cannot use abstract or final. Without #[\Tyhp\GenericRuntime] they splice (or erase) — PHP cannot call them. A compiled-library mapping that stamps GenericRuntime is not a splice candidate; foreign $s->jsonDecodeAs<User>() goes through \Tyhp\Generic::bind rather than baking the binder name.
| Form | Meaning | Emitter |
|---|---|---|
operator +(…): T; (no extension) |
Native PHP operator | No rewrite — leave $a + $b |
extension operator +(…): T => …; |
Thin mapping | Splice the => expression |
extension operator +(…): T; (bodyless) |
Illegal | Diagnostic TYHP8013 |
<?tyhpdef
namespace Decimal;
final class Decimal {
// Native on the PECL extension — type-check only; emit keeps `$a + $b`
operator +(self $left, Decimal|string|int $right): self;
}
class Money {
public function plus(Money $other): Money;
public function isEqualTo(Money $other): bool;
extension operator +(self $left, self $right): self => $left->plus($right);
extension operator ==(self $left, self $right): bool => $left->isEqualTo($right);
extension fn formatCurrency(string $locale = 'en_US'): string
=> $locale . ' ' . $this->__toString();
extension fn shortLabel(): string => $this->formatCurrency();
}
A tyhpdef class can pull in standalone extension declarations with use extension, including trait-like adaptations (as aliases, insteadof precedence). Types that declare use extension in tyhpdef can auto-activate those extensions for callers without a separate use extension in every Tyhp file (see binder / resolution behavior).
<?tyhpdef
class Money {
public function plus(Money $other): Money;
public function format(): string;
use extension MoneyFormatting {
MoneyFormatting::format as formatExtended;
};
}
use extension is opt-in by default, including for tyhpdef-declared extensions. Extensions from other namespaces or files are imported with use extension instead of a regular use statement. This makes it explicit that you are bringing extension methods into scope.
The import supports trait-like adaptations using curly braces: method as aliases, postfix hide, and insteadof (including on operators). as on operators is an error (TYHP4170) — use hide or insteadof. hide is a contextual keyword only inside { } adaptations (so function hide() elsewhere stays valid).
<?tyhp
// Import an extension and bring all its methods into scope
use extension App\Extensions\StringExtensions;
// Import with adaptations (like traits)
use extension App\Extensions\ArrayExtensions {
ArrayExtensions::first as firstItem;
};
string $text = 'hello_world';
echo $text->toCamelCase(); // from StringExtensions
array<int> $nums = [1, 2, 3];
$first = $nums->firstItem(); // aliased from ArrayExtensions::first
<?tyhp
use extension StringOperators {
StringOperators::toUpper as toCC;
StringOperators::toSnakeCase hide;
StringOperators::operator *<string> hide;
operator *<array> hide; // qualifier optional when this `use` lists one extension
};
use extension StringOperators, RepeatOps {
RepeatOps::operator *<string> insteadof StringOperators;
};
operator *<string> hide hides that target only. operator * hide hides every * member on that extension.operator convert hide hides every convert on that extension.operator +<Money> hide hides all + members for that target from this extension (unary and binary together).TYHP4173.insteadof RHS may be any extension in scope, including a same-file extension { } that was not listed on this use.global use extension (C# global using, not PHP global $var) activates an extension for the entire compilation that loaded that file. tyhp/core authors global use extension \Tyhp\StringExtensions; (and the matching Array / Int / Float / Bool / Closure extensions) so consumers get $s->length() without a per-file import.
<?tyhp
use extension \Tyhp\StringExtensions {
StringExtensions::toLower hide;
};
string $s = "Hello";
int $len = $s->length();
A local use extension \Tyhp\StringExtensions; with no adaptations warns (TYHP4169). Generated library tyhpdef does not emit global use extension for the library’s own extensions. See Use statements and Scalar Pseudo-Objects.
Extension method calls can be chained. Each link is rewritten independently: a spliced member substitutes its expression; a multi-statement member becomes a static call, with the previous result as the first argument.
<?tyhp
string $result = \trim($input)
->toSnakeCase()
->truncate(50);
<?php
$result = StringExtensions::truncate(
StringExtensions::toSnakeCase(
\trim($input)
),
50
);
Extension methods can only access public members of the extended type. They cannot access private or protected members because extensions are external to the class hierarchy.
Use extensions to add utility methods to types you don't own — they keep your code clean without subclassing or wrapper patterns.
Organize extensions logically by the type they extend (e.g., StringExtensions, ArrayExtensions, DateTimeExtensions).
Import only the extensions you need with use extension — this is opt-in. Loading a library does not auto-activate its extension { } blocks unless that tyhpdef contains global use extension. The tyhp/core catalog is already globally imported; do not re-import \Tyhp\StringExtensions unless you are hiding or renaming methods.
Put the target's type parameters on the extension or group, and add method type parameters only when that method needs its own.
Keep extension methods pure when possible — they should compute a result from the extended value without side effects.
Do not create overlapping extensions for the same type without clear purpose — if two extensions define the same method name for the same type, the compiler reports a conflict.
Do not try to access private or protected members from extension methods — extensions can only use the public API of the extended type.
Give every extension a header extends Type or nested extends groups. Members with no target are TYHP4147. A header target and nested groups in one extension are TYHP4362.
Do not use regular use statements for extensions — always use use extension to bring extension methods into scope.
Do not re-import \Tyhp\StringExtensions (or the other tyhp/core scalar extensions) with a local use extension unless you are hiding or renaming methods. A redundant non-mutating local use warns (TYHP4169).
Do not write a parameter that a spliced member mutates without &, and do not mark & on a parameter the expression never writes (TYHP4174). Writing $this requires &$this (TYHP4369); &$this with no write is TYHP4370. A non-mutating increment is $v + 1, not ++$v.
Do not put #[\Tyhp\Optimize\Inline] on an extension member (TYHP4176). Form decides splicing.
<?tyhp
// ERROR TYHP4147: members and no target
// extension Bad {
// function doSomething(string $value): string { /* ... */ }
// }
// ERROR: conflicting extension methods
// extension StringHelpers1 extends string {
// function clean(): string { /* ... */ }
// }
// extension StringHelpers2 extends string {
// function clean(): string { /* ... */ }
// }
// use extension StringHelpers1;
// use extension StringHelpers2; // Error: conflicting 'clean' method
// ERROR: accessing private member
// extension UserExtensions extends User {
// function getPasswordHash(): string {
// return $this->passwordHash; // Error: private property
// }
// }
#[\Tyhp\Php] is illegal on a Tyhp extension { } (TYHP4304). Gate an extension with declare(php=…) instead — file-level declare(php=">=8.4"); or a brace block:
<?tyhp
declare(php=">=8.4") {
extension GatedStringOps extends string {
function gated_tag(): string {
return \strtoupper($this);
}
}
}
See PHP Version Gating.
extends or nested extends group (TYHP4147).extends and a nested group in one extension (TYHP4362), or a member outside a group when groups are present (TYHP4363).void, never, mixed, a type parameter, an object shape, _, or a union containing one of those (TYHP4364).TYHP4365), or a method type parameter that reuses one of those names (TYHP4366).TYHP4367).static:: or parent:: in an extension member (TYHP4368).$this without &$this (TYHP4369), or &$this when the body never writes $this (TYHP4370).extends Type $this or operator op<Type> (TYHP4361). Put the target on the block. use extension adaptations still write operator +<Money>.TYHP3016). An operator whose target is void, never, null, mixed, resource, true, or false (TYHP3025).extension fn / extension operator take the enclosing type as the receiver. They do not use a header extends.extension Name {} (TYHP4172).as on an operator in a use extension adaptation (TYHP4170); use hide or insteadof.hide of a member the extension does not declare (TYHP4173).__tyhpExtensionBacker (TYHP4171).extension fn / extension operator.&, declares & on a parameter it does not write, or mutates $this without & (TYHP4174).TYHP4175).#[\Tyhp\Optimize\Inline] on an extension member (TYHP4176).TYHP4177).& parameter is not something PHP can reference (TYHP4180).TYHP4181).#[\Tyhp\Php] on an extension declaration (TYHP4304). Use declare(php=…) (file-level ; form today).