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. `extension Name extends ApiClient` is optional.
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();

Auto-Generated Magic Methods for Generics

When a class uses generics with runtime type tracking (for example typeof(T)), 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(...)). Generic classes that never use typeof(T) or other tracking triggers emit no trait.

<?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}";
    }
}

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

Constructor Return Type Syntax

Tyhp constructors must declare a return type: : void, or : parent(args) to insert parent::__construct(...) at the start of the body. 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

Relying on __get() and __set() for dynamic property creation. Tyhp disables dynamic property creation on classes — all properties must be declared in the class definition. Use __get/__set only for controlled access to a backing store like an array.

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). 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];
    }
}