Skip to content
16

Generics Basics & Bounds ​

Generics parameterize classes, interfaces, and functions, allowing you to define reusable containers and algorithms that strictly enforce specific types at runtime.


What Are Generics and Why Do You Need Them? ​

Without generics, a container class (like a Collection or List) or a wrapper service (like a Repository or Response) can only accept or return un-typed mixed or generic object:

php
// Without Generics:
$users = new Collection();
$users->add(new User('Alice'));
$users->add(new Product('SKU-100')); // Accidental bug! Collection holds mixed types.

To prevent bugs without generics, you would have to write repetitive runtime type checks (if (!$item instanceof User) throw ...) inside every loop or method body.

Generics solve this problem. Generics allow you to pass a type parameter (like <User>) into a class or function signature. It parameterizes the container, telling PHP: "This specific Collection instance holds ONLY User objects."

php
// With Generics:
/** @var Collection<User> $users */
$users = new Collection();
$users->add(new User('Alice')); // Valid
$users->add(new Product('SKU-100')); // TypePHP blocks this instantly at runtime!

How Runtime Generics Work ​

TypePHP manages generic templates at two distinct execution levels:

  1. Function-Level Templates: Bound per call site (e.g., collectSameType(...$items)).
  2. Class-Level Templates: Bound to specific object instances in memory using PHP's native WeakMap (e.g., Collection<User>).

Deep Enforcement vs. Boundary Enforcement (Included vs. Excluded Classes) ​

It is crucial to understand when TypePHP intercepts a generic type violation. This depends entirely on whether the generic class (e.g., Collection) is included or excluded in your typephp.php configuration.

1. Included Classes (Deep Enforcement) ​

If you want TypePHP to throw an error the exact millisecond you call an internal method like $collection->add(new Product()) or new Collection([1, 'invalid']), the generic class itself must be whitelisted in your typephp.php include array.

When a class is included, TypePHP intercepts every method call and constructor inside it.

php
// 1. You explicitly included your custom collection:
// 'include' => ['app/Support/MyCollection.php']

/** @var MyCollection<int, User> $users */
$users = new MyCollection();

// Throws TypeError IMMEDIATELY inside MyCollection::add()
// "Argument $item (template TValue) must be of type User, Product given"
$users->add(new Product());

2. Excluded Classes (Boundary Enforcement) ​

Most third-party generic containers (like Laravel's Illuminate\Support\Collection or Doctrine's ArrayCollection) live in vendor/**, which is usually excluded for maximum framework performance.

When a generic class is excluded, TypePHP does not intercept its internal constructors or methods, and does not inspect its internal properties. Instead, it tracks the object's generic "badge" in memory and strictly enforces the contract by comparing badges when the object crosses into an included function or method.

php
// 1. Laravel Collections are excluded:
// 'exclude' => ['vendor/**']

// 2. You label the collection in your app/ code:
/** @var Illuminate\Support\Collection<int, string> $strings */
$strings = new Collection(['valid_string', 12345]); 

// NO ERROR THROWN YET!
// Because Collection.php is excluded, TypePHP does not intercept the constructor 
// to check the raw array contents. It simply attaches the `string` badge to the object.

// 3. BOUNDARY GATEKEEPER CATCHES TYPE CONFLICTS
class NumberService {
    /** @param Collection<int, int> $numbers */
    public function process(Collection $numbers) {}
}

$service = new NumberService();

// Throws TypeError at the function boundary!
// "Argument $numbers expects Collection<covariant int>, but Collection<string> was given"
$service->process($strings);

Why this happens: TypePHP doesn't open the sealed "Vendor Box" to look at the 12345 or 'valid_string' inside it. Instead, the NumberService acts as a gatekeeper:

  • "I require a box labeled int."
  • "You handed me a box labeled string."
  • Access Denied.

Which should you choose? ​

  • Exclude generic framework classes (vendor/**) to keep the framework booting at 100% native C-speed. TypePHP will still perfectly guard your application code (app/**) by ensuring no generic containers with conflicting type badges ever cross into your services or controllers.
  • Include specific generic classes if you own them (e.g. app/Data/Result.php), or if you specifically want deep, immediate validation on every single method call and constructor argument within that container.

PHP Limitation: No Native instanceof on Generics ​

In PHP, writing generic syntax directly in executable statements (such as if ($obj instanceof Collection<User>)) is a syntax error.

In TypePHP, you declare generics in standard PHPDoc annotations (@param Collection<User> $users or /** @var Collection<User> $users */). TypePHP's AST engine intercepts these annotations and enforces generic contracts at runtime without altering native PHP syntax rules.


Basic Template Annotations (@template T) ​

When a function uses @template T across multiple parameters, TypePHP infers T from the first argument and enforces consistency across all subsequent arguments:

php
<?php

declare(strict_types=1);

/**
 * @template T
 *
 * @param T ...$items
 * @return list<T>
 */
function collectSameType(mixed ...$items): array
{
    return $items;
}

// Valid Call (Infers T = int)
collectSameType(10, 20, 30);

// Invalid Call (Infers T = int from item #1, then item #3 'invalid' fails T = int)
collectSameType(10, 20, 'invalid');
// Throws: TypeError: collectSameType(): Argument $items[2] (template T = int) must be of type int

Multiple Generic Templates (@template T, @template U) ​

Functions and classes are not limited to a single template parameter. You can declare multiple independent generic templates (such as T, U, K, V):

php
/**
 * @template T of Animal
 * @template U of Car
 *
 * @param T $animal
 * @param U $car
 */
function pairUp(Animal $animal, Car $car): void
{
    // T is bound to Animal subtype, U is bound to Car subtype
}

// Valid Call (T = Dog, U = SportsCar)
pairUp(new Dog(), new SportsCar());

// Invalid Call (Swapped arguments: T receives Car, U receives Dog)
pairUp(new Car(), new Dog());
// Throws: TypeError: pairUp(): Argument $animal (template T) must be an instance of Animal, Car given

Generic Template Upper Bounds (@template T of Bound) ​

Use @template T of Bound to restrict template arguments to a specific class hierarchy, interface, or scalar range:

php
abstract class Animal {}
class Dog extends Animal {}
class Car {} // Not an Animal!

/**
 * @template T of Animal
 *
 * @param T $animal
 * @return T
 */
function processAnimal(Animal $animal): Animal
{
    return $animal;
}

// Valid Call (Dog extends Animal)
processAnimal(new Dog());

// Invalid Call (Car does not extend Animal)
processAnimal(new Car());
// Throws: TypeError: processAnimal(): Argument $animal (template T) must be an instance of Animal, Car given

Default Generic Templates (@template T = DefaultType) ​

If a template parameter T cannot be inferred from function arguments, TypePHP uses the declared default type:

php
/**
 * @template T = string
 *
 * @param mixed $value
 * @return T
 */
function getDefaultValue(mixed $value): mixed
{
    return $value;
}

// Valid Call (Unbound T falls back to default: string)
getDefaultValue('valid_string');

// Invalid Call (Return value violates default string type)
getDefaultValue(12345);
// Throws: TypeError: getDefaultValue(): Return value must be of type string

Upper Bounds with Defaults (@template T of Bound = DefaultType) ​

Combine upper bounds with defaults (@template T of Bound = DefaultType) to enforce class inheritance while providing a fallback type:

php
/**
 * @template T of object = stdClass
 *
 * @param mixed $value
 * @return T
 */
function getObjectInstance(mixed $value): mixed
{
    return $value;
}

// Valid Call (stdClass satisfies default stdClass)
getObjectInstance(new stdClass());

// Invalid Call (Dog is an object, but violates default stdClass)
getObjectInstance(new Dog());
// Throws: TypeError: getObjectInstance(): Return value must be an instance of stdClass

Generics of Scalars, Refinements, and Array Shapes ​

Generic parameters (T) in TypePHP are not limited to object classes. You can bind generics to refined scalar types (positive-int, non-empty-string) or complex array shapes (array{id: positive-int}):

Generic Collections of Refined Scalars (Collection<positive-int>) ​

php
/** @var MyCollection<positive-int> $scores */
$scores = new MyCollection();

$scores->add(100); // Valid

$scores->add(-50); 
// Throws: TypeError: MyCollection::add(): Argument $item (template T = positive-int) must be of type positive-int

Generic Collections of Array Shapes (Collection<array{...}>) ​

php
/** @var MyCollection<array{id: positive-int, name: non-empty-string}> $userShapes */
$userShapes = new MyCollection();

$userShapes->add(['id' => 1, 'name' => 'Alice']); // Valid

$userShapes->add(['id' => -5, 'name' => 'Alice']); 
// Throws: TypeError: MyCollection::add(): Argument $item['id'] must be of type positive-int

First-Use Type Inference (Unannotated Generic Instances) ​

If you instantiate an included generic class without an inline @var prebinding annotation:

php
$collection = new MyCollection(); // No @var MyCollection<User> annotation!

TypePHP automatically infers the template parameter T from the first method call executed on that object instance and locks T to that type in WeakMap memory for all subsequent calls:

php
// 1. First method call infers T = User and locks T to User for this instance!
$collection->add(new User('Alice'));

// 2. Subsequent call succeeds because argument is a User
$collection->add(new User('Bob'));

// 3. Subsequent call fails because T was locked to User on first use!
$collection->add(new Product('SKU-100'));
// Throws: TypeError: MyCollection::add(): Argument $item (template T = User) must be of type User, Product given

Simultaneous First-Use Multi-Template Inference ​

If you instantiate an included multi-template class without an inline @var annotation:

php
/**
 * @template K of array-key = string
 * @template V = int
 */
class MultiTemplateBag
{
    private array $storage = [];

    /**
     * @param K $key
     * @param V $val
     */
    public function set(mixed $key, mixed $val): void
    {
        $this->storage[$key] = $val;
    }
}
  1. Simultaneous Inference: The very first method call (e.g. $bag->set('timeout', 30)) infers and locks all active template parameters simultaneously (K = string, V = int) in WeakMap memory.
  2. Lock-In: All subsequent method calls on that instance enforce both locked types:
php
$bag = new MultiTemplateBag();

// 1. First method call infers K = string and V = int simultaneously
$bag->set('max_retries', 5);

// 2. Subsequent call matching K = string and V = int succeeds
$bag->set('timeout', 30); // Valid

// 3. Subsequent call violating locked K = string fails
$bag->set(12345, 30);
// Throws: TypeError: MultiTemplateBag::set(): Argument $key (template K = string) must be of type string

// 4. Subsequent call violating locked V = int fails
$bag->set('timeout', 'thirty');
// Throws: TypeError: MultiTemplateBag::set(): Argument $val (template V = int) must be of type int