PHP Magic Methods in Tyhp

Tier 0 · Story 08Complete

Tyhp supports all of PHP's magic methods with additional type safety requirements. Magic methods that return mixed in PHP must have their return values type-narrowed before use. The compiler enforces correct signatures, auto-generates certain magic methods for generic classes, and prioritizes extension methods over __call for compile-time-safe dispatch.

Supported Magic Methods

All standard PHP magic methods are supported in Tyhp. They must follow PHP's expected signatures but can use Tyhp's enhanced type system. The following table lists every supported magic method and its Tyhp-specific behavior.

Type Safety with mixed Returns

Magic methods like __get(), __call(), __callStatic(), and __invoke() return mixed in PHP. In Tyhp, the mixed type must be narrowed before the value can be used in a typed context. This means you must use type narrowing (is checks, instanceof) or type assertions before using the return value.

<?tyhp

class DynamicConfig {
    private array $data = [];

    public function __get(string $name): mixed {
        return $this->data[$name] ?? null;
    }

    public function __set(string $name, mixed $value): void {
        $this->data[$name] = $value;
    }
}

DynamicConfig $config = new DynamicConfig();
$config->dbHost = 'localhost';

// ERROR: Cannot use mixed directly without narrowing
// string $value = $config->dbHost;

// OK: Narrow with is check first
mixed $raw = $config->dbHost;
if ($raw is string) {
    string $value = $raw; // OK: narrowed to string
    echo $value;
}

The recommended pattern is to implement type-safe wrapper methods that internally use __get/__set but expose typed interfaces to callers.

<?tyhp

class TypedConfig {
    private array $data = [];

    public function __get(string $name): mixed {
        return $this->data[$name] ?? null;
    }

    // Type-safe wrapper methods — preferred approach
    public function getString(string $key, string $default = ''): string {
        mixed $val = $this->data[$key] ?? $default;
        return $val is string ? $val : $default;
    }

    public function getInt(string $key, int $default = 0): int {
        mixed $val = $this->data[$key] ?? $default;
        return $val is int ? $val : $default;
    }
}

Extension Methods vs __call

Tyhp's extension methods are resolved at compile time and provide full type checking. When both an extension method and a __call magic method could handle a method call, the extension method takes priority. This means extension methods are always checked first — __call is only invoked when no matching extension method exists.

<?tyhp

class ApiClient {
    // __call handles unknown methods at runtime
    public function __call(string $name, array $args): mixed {
        return $this->sendRequest($name, $args);
    }
}

// Extension method — compile-time type safety.
// Members are top-level `function` forms (optional `async` only).
// Visibility/`static` cannot appear. Extensions cannot `extends`; the receiver type
// comes from `extends ApiClient $client` on the parameter itself.
extension ApiClientExtensions {
    function getUsers(extends ApiClient $client): array<User> {
        mixed $result = $client->sendRequest('getUsers', []);
        // narrowing and type-safe return
        return $result is array ? $result : [];
    }
}

ApiClient $api = new ApiClient();

// This calls the extension method (type-safe, compile-time checked)
array<User> $users = $api->getUsers();

// This falls through to __call (returns mixed, requires narrowing)
mixed $result = $api->unknownMethod();
<?php

// Extension method compiles to static call:
$users = ApiClientExtensions::getUsers($api);

// __call compiles to standard PHP magic method dispatch:
$result = $api->unknownMethod();

Property Hooks (PHP 8.4)

Tyhp supports PHP 8.4 property hooks (get / set) on class properties. Hook bodies match PHP: a parameter-less get { ... }, optional set { ... } (the incoming value is $value), or a typed set(string $value) { ... }. &get and final get / final set are also valid.

When output.phpVersion is 8.4 or later, the compiler emits native hook syntax. When the target is below 8.4, it polyfills the same source through \Tyhp\Concerns\UsesPropertyAccessors and $this->__tyhpPropertyHook. &get requires PHP 8.4.

<?tyhp

final class Widget {
    private string $_name = '';

    public string $name {
        get {
            return $this->_name;
        }
        set(string $value) {
            $this->_name = $value;
        }
    }
}

See Object Declaration Changes for tyhpdef { get; set; } form.

Auto-Generated Magic Methods for Generics

When a class uses generics with runtime type tracking, the Tyhp compiler adds the HasGenerics trait from the tyhp/core package (\Tyhp\Concerns\HasGenerics). Concerns\GenericObject is the legacy name. The compiler also auto-generates constructor logic to initialize the generic type information (__initGenerics__tyhpGeneric and $this->__tyhpGeneric->init(...)).

Tracking is on when the class uses typeof(T) / default(T) / is T / new T() / forwarded type arguments, or when it has a generic-typed instance property (including promoted constructor properties) that is not opted out with #[\Tyhp\EraseGeneric]. Mixed mode is allowed: the class stays tracked while opted-out properties skip set checks. Fully erased generic classes emit no trait. The attribute is not inherited.

<?tyhp

class TypedCollection<T> {
    private array $items = [];

    public function add(T $item): void {
        $this->items[] = $item;
    }

    public function get(int $index): T {
        return $this->items[$index];
    }

    public function getItemType(): \Tyhp\Type {
        return typeof(T);
    }
}
<?php

class TypedCollection {
    use \Tyhp\Concerns\HasGenerics;

    private array $items = [];

    public function add(mixed $item): void {
        $this->items[] = $item;
    }

    public function get(int $index): mixed {
        return $this->items[$index];
    }

    protected function __initGenerics__tyhpGeneric(?\Tyhp\Type ...$generics): void
    {
        $this->__tyhpGeneric->init(static::class, \TypedCollection::class, new \Tyhp\NamedType('T', $generics[0] ?? null));
        $this->__tyhpGeneric->markBound();
    }
}

__toString Must Return string

The compiler strictly enforces that __toString() returns string. Unlike PHP which coerces the return value, Tyhp treats a non-string return type as a compiler error.

<?tyhp

class Money {
    public function __construct(
        private int $amount,
        private string $currency
    ): void {}

    // OK: returns string
    public function __toString(): string {
        return "{$this->amount} {$this->currency}";
    }
}

A class or interface that declares __toString(): string is a \Stringable type. Echo, concatenation, interpolation, and assignability to \Stringable follow that rule. Written implements \Stringable is still allowed.

<?tyhp

class Money {
    public function __construct(
        private int $amount,
        private string $currency
    ): void {}

    public function __toString(): string {
        return "{$this->amount} {$this->currency}";
    }
}

function label(\Stringable $value): void {}

Money $pay = new Money(10, "USD");
label($pay);
echo $pay;
<?tyhp

// ERROR: __toString must return string
// class Bad {
//     public function __toString(): int { return 42; }
// }

Constructor Return Type Syntax

Tyhp constructors may declare a return type: : void (or omit it) when the constructor does not chain, or : parent(args) to insert parent::__construct(...) at the start of the body. Omitting the return type is equivalent to : void. PHP output strips the annotation.

<?tyhp

class Base {
    public function __construct(public string $name): void {}
}

class Child extends Base {
    public function __construct(
        string $name,
        public int $age
    ): parent($name) {
        // parent::__construct($name) is called automatically
    }
}
<?php

class Base {
    public function __construct(public string $name) {}
}

class Child extends Base {
    public function __construct(
        string $name,
        public int $age
    ) {
        parent::__construct($name);
    }
}

__clone and the with Keyword

clone ... with on readonly properties does not require you to write __clone(). On PHP 8.5+ the compiler emits native clone($obj, [...]). On PHP 8.2–8.4 it uses a compiler-generated wrapper (opt-in via build.experimentalReadonlyCloneWith). You can still write your own __clone() for other clone-time work.

<?tyhp

class Point {
    public function __construct(
        public readonly int $x,
        public readonly int $y
    ): void {}
}

Point $p1 = new Point(1, 2);
Point $p2 = clone $p1 with [x => 10]; // $p2->x is 10, $p2->y is 2

Best Practices

Tip

Implement type-safe wrapper methods (getString(), getInt(), etc.) instead of relying on __get() and __set() directly. Wrapper methods provide compile-time type checking and eliminate the need for callers to narrow mixed return values.

Tip

Use extension methods instead of __call() whenever possible. Extension methods are resolved at compile time, provide full type safety, and compile to static method calls — while __call() loses all type information and requires runtime narrowing.

Tip

Use operator overloads instead of relying on __toString() for arithmetic or comparison operations. Operator overloads provide explicit, type-safe semantics.

Tip

Use readonly properties with clone ... with for immutable value objects. You do not need a handwritten __clone() for that pattern.

Common Mistakes

Danger

Using __get/__set as a substitute for declared properties on ordinary classes. Undeclared writes are legal only on exact \stdClass. Other types — including class Bag extends \stdClass — must declare properties or implement __set (TYHP4134). Use __get/__set when you need a controlled backing store.

Danger

Using __call() for type-safe method dispatch. __call() returns mixed and provides no compile-time type information. Use function overloads or extension methods instead for type-safe dispatch patterns.

Danger

Assuming mixed return values from magic methods are a specific type without narrowing. Always use is checks or type assertions before using values returned from __get(), __call(), __callStatic(), or __invoke().

Danger

Returning a non-string value from __toString(). The compiler enforces that __toString() must have a string return type.

Danger

Assuming clone ... with on readonly works on PHP 8.2–8.4 without build.experimentalReadonlyCloneWith: true. Enable that flag, or target PHP 8.5+.

Compiled PHP Output

Magic methods compile to standard PHP magic methods. The compiler does not transform the magic method signatures or bodies — they pass through to PHP directly. The only additions are auto-generated generic-tracking members when needed (HasGenerics trait, __initGenerics__tyhpGeneric). Property hooks on output.phpVersion below 8.4 emit the UsesPropertyAccessors polyfill rather than native PHP 8.4 hook syntax. Readonly clone ... with on PHP < 8.5 may emit a separate wrapper, not a replacement of your own __clone().

<?tyhp

class Entity {
    public function __serialize(): array {
        return ['id' => $this->id, 'name' => $this->name];
    }

    public function __unserialize(array $data): void {
        $this->id = $data['id'];
        $this->name = $data['name'];
    }

    public function __debugInfo(): array {
        return ['id' => $this->id];
    }
}
<?php

// Magic methods pass through to PHP unchanged
class Entity {
    public function __serialize(): array {
        return ['id' => $this->id, 'name' => $this->name];
    }

    public function __unserialize(array $data): void {
        $this->id = $data['id'];
        $this->name = $data['name'];
    }

    public function __debugInfo(): array {
        return ['id' => $this->id];
    }
}