---
title: 'Type Declarations'
source: 'https://academia.sh/en/courses/php-object-model/type-declarations'
course: 'Object-Oriented PHP and Standards'
language: en
updated: '2026-10-21T05:03:35+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Declares parameter, return, and property types and writes ?int and a union type'
    - 'Measures that adding int to an untyped parameter stops a consumer that passes null'
    - 'Shows that the same addition does not reach a consumer that uses ?int or sends a numeric string'
---

# Type Declarations

Parameter, return, and property types, ?int and the union type, and which inputs stop the consumer when a type is added to a parameter.

Magic methods resolved names loosely; a type declaration places a gate at the boundary of the class. The question is which inputs stop the consumer when `int` is written on an untyped parameter.

## Type Declarations

A type written before the name of a parameter, a return value, or a property limits the values PHP will accept at that point. `?int` means either `int` or `null`. `int|string` is a union type: it accepts one of two types.

```php
<?php
// Vat.php — parameter, return, ?int, and union type
class Vat
{
    public static function add(int $amount): float
    {
        return $amount * 1.2;
    }

    public static function describe(?int $count): string
    {
        return $count === null ? 'none' : 'some';
    }

    public static function kind(int|string $code): string
    {
        return gettype($code);
    }
}
```

```php
<?php
// vat_usage.php — inputs that match the type pass, those that do not raise a TypeError
require 'Vat.php';

var_dump(Vat::add(100));
echo Vat::describe(null), ' ', Vat::describe(7), "\n";
echo Vat::kind(5), ' ', Vat::kind('a'), "\n";

try {
    Vat::add('abc');
} catch (TypeError $e) {
    echo get_class($e), "\n";
}
```

```
float(120)
none some
integer string
TypeError
```

**OM25.** `add()` took only `int` and threw a `TypeError` when `'abc'` came; `describe()` accepted `null` because its type was `?int`. The details of this behavior, that is `TypeError`, `E_DEPRECATED`, and `strict_types`, were measured in the Variables and Data Types lesson of M12/K01 and are not repeated here. This lesson asks whether the addition of a type declaration reaches the consumer.

## Return Types: void and never

`void` says that the method returns no value; the result of the call is always `null`, and binding it to a value is meaningless. `never` is a stronger promise: the method never returns; it either throws an exception, ends the process, or runs forever. If a method declared `never` quietly comes to an end, PHP reports this with a `TypeError`.

```php
<?php
// Flow.php — the void and never return types
class Flow
{
    public static function write(string $message): void
    {
        echo $message, "\n";
    }

    public static function halt(string $reason): never
    {
        throw new RuntimeException($reason);
    }

    public static function brokenHalt(): never
    {
        echo "inner\n";
    }
}

var_dump(Flow::write('a'));

try {
    Flow::halt('x');
} catch (RuntimeException $e) {
    echo get_class($e), ' ', $e->getMessage(), "\n";
}

try {
    Flow::brokenHalt();
} catch (TypeError $e) {
    echo get_class($e), "\n";
}
```

```
a
NULL
RuntimeException x
inner
TypeError
```

`write()` wrote something and the value of the call came out `null`. `halt()` never returned, and the exception was caught. `brokenHalt()` ran its body but received a `TypeError` when it tried to return at the end; code that does not keep the promise of `never` is caught by the type itself.

## Property Type and the Uninitialized Property

A property type checks every value written to the property. A typed property without a default value is in an uninitialized state; this is not the same as being `null`. Reading an uninitialized property raises an `Error`. On a typed property that accepts `null`, if a default value is written, reading gives `null`.

```php
<?php
// Profile.php — a property type and an uninitialized property
class Profile
{
    public int $age;
    public ?string $name = null;
}

$profile = new Profile();
var_dump($profile->name);

try {
    echo $profile->age;
} catch (Error $e) {
    echo get_class($e), "\n";
}

try {
    $profile->age = 'abc';
} catch (TypeError $e) {
    echo get_class($e), "\n";
}

$profile->age = '42';
var_dump($profile->age);
```

```
NULL
Error
TypeError
int(42)
```

`name` carried `null` by default, so it could be read; `age`, never having been written, gave an `Error`. `'abc'` could not be converted to a number, so a `TypeError` arose, while `'42'` was coerced to `int` in a file without strict mode. The property type is therefore two separate promises: the type of the value written, and that it cannot be read before it is written.

## mixed and Coercion in a Union Type

`mixed` accepts every value, `null` included. What separates it from writing no type is that it says the type was left open on purpose. In a union type, if the value fits none of the members exactly, PHP tries a coercion in a file without strict mode and chooses by rule which member to convert it to: a string that can be read as a whole number becomes `int`, and a string with a fractional part becomes `float`.

```php
<?php
// Accept.php — coercion in mixed and in an int|float union type
class Accept
{
    public static function any(mixed $value): string
    {
        return gettype($value);
    }

    public static function number(int|float $number): string
    {
        return gettype($number);
    }
}

echo Accept::any(null), ' ', Accept::any([1]), "\n";
echo Accept::number('1.5'), ' ', Accept::number('7'), ' ', Accept::number(true), "\n";
```

```
NULL array
double integer integer
```

`mixed` rejected no value. `int|float` converted the string `'1.5'` to `float` (`gettype` writes this as `double`), and the string `'7'` and the value `true` to `int`. A union type does not preserve the type of an input; it may convert it to one of the members it accepts. So whoever limits the type has also limited which conversions are accepted.

## The static Return Type

If the return type of a method is `static`, it says that the method will return an object of whichever class it was called on. `self`, by contrast, points to the class in which the method was written. If methods that are called in a chain use `static`, the object of the subclass stays a subclass along the chain.

```php
<?php
// Query.php — the static return type keeps the subclass in a chain
class Query
{
    private array $parts = [];

    public function where(string $condition): static
    {
        $copy = clone $this;
        $copy->parts[] = $condition;
        return $copy;
    }

    public function size(): int
    {
        return count($this->parts);
    }
}

class SpecialQuery extends Query
{
}

$query = (new SpecialQuery())->where('a')->where('b');
echo get_class($query), ' ', $query->size(), "\n";
```

```
SpecialQuery 2
```

Although `where()` is written in the `Query` class, the object returned was `SpecialQuery` because it was called on `SpecialQuery`. Had the return type been written as `self`, the type would be read as `Query`, and the fact that the object at the end of the chain is a subclass would be lost at the type level. This difference matters for code that will extend a class later: being able to call the subclass's own methods at the end of the chain depends on whether the return type preserves the subclass.

## Type Compatibility in Inheritance

When a subclass redefines a method, its types have to be compatible with the parent class. The rule is this: the return type can be narrowed (if the parent returns `iterable`, the subclass can return `array`, because every `array` is an `iterable`), while the parameter type can be widened but not narrowed. The guarantees that the parent's code expects must not weaken in the subclass.

```php
<?php
// Source.php — narrowing the return type is allowed
class Source
{
    public function provide(): iterable
    {
        return [1];
    }
}

class Narrower extends Source
{
    public function provide(): array
    {
        return [1, 2];
    }
}

echo count((new Narrower())->provide()), "\n";
```

```
2
```

Code that tries the opposite stops at the class declaration stage.

```bash
# incompatible.sh — widening the return type and narrowing the parameter type make a class undeclarable
cat > Wide.php <<'PHP'
<?php
class Base { public function provide(): array { return []; } }
class Child extends Base { public function provide(): iterable { return []; } }
PHP
cat > Narrow.php <<'PHP'
<?php
class Base { public function take(int|string $input): void {} }
class Child extends Base { public function take(int $input): void {} }
PHP
php -d display_errors=0 Wide.php; echo "wide exit=$?"
php -d display_errors=0 Narrow.php; echo "narrow exit=$?"
```

```
wide exit=255
narrow exit=255
```

Both hit the same principle. Every object placed in a variable of type `Base` promises that `provide()` will return an `array` when called; a subclass that returns `iterable` breaks this promise. Likewise, if `take()` accepts both `int` and `string`, a subclass that accepts only `int` can reject a call written with the parent's type.

## The Measurement Core

The measurement in this lesson also uses the `reach.php` file; the core was explained line by line in the first lesson.

```php
<?php
// reach.php — helpers that measure where a change reaches

function write_files(string $dir, array $files): void
{
    foreach ($files as $name => $content) {
        if ($content === null) {
            continue; // null: the file was deleted
        }
        $path = "$dir/$name";
        if (!is_dir(dirname($path))) {
            mkdir(dirname($path), 0777, true);
        }
        file_put_contents($path, $content);
    }
}

function read_files(string $dir): array
{
    $files = [];
    $walker = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS)
    );
    foreach ($walker as $file) {
        $name = substr($file->getPathname(), strlen($dir) + 1);
        $files[$name] = file_get_contents($file->getPathname());
    }
    return $files;
}

function run(string $dir): array
{
    $command = 'cd ' . escapeshellarg($dir) . ' && php -d display_errors=0 main.php 2>/dev/null';
    exec($command, $lines, $code);
    return [$code, implode("\n", $lines)];
}

function measure(string $name, array $base, array $change, array $seams = []): string
{
    if (array_key_exists('main.php', $change)) {
        throw new LogicException("$name: the consumer is not edited");
    }
    write_files("measure/$name/before", $base);
    write_files("measure/$name/after", array_merge($base, $change));
    [$codeBefore, $outputBefore] = run("measure/$name/before");
    [$codeAfter, $outputAfter] = run("measure/$name/after");
    if ($codeBefore !== 0) {
        throw new LogicException("$name: the baseline did not run");
    }
    $reach = match (true) {
        $codeAfter !== 0 => 'breaking',
        $outputBefore !== $outputAfter => 'implicit',
        array_intersect(array_keys($change), $seams) !== [] => 'at-seam',
        default => 'in-place',
    };
    printf(
        "%-18s files=%d  exit=%-3d output %-7s -> %s\n",
        $name,
        count($change),
        $codeAfter,
        $outputBefore === $outputAfter ? 'same' : 'changed',
        $reach
    );
    return $reach;
}
```

## Adding a Type to the Parameter

The measured change is turning the untyped `add($amount)` method into `add(int $amount): float`. The consumer `main.php` leaves the variable as `null`, calls `Vat::add($amount)`, and prints the result with `var_dump`.

```php
<?php
// parameter_type.php — int is added to an untyped parameter, the consumer passes null
require_once 'reach.php';

measure('parameter-type', [
    'vat.php' => <<<'PHP'
        <?php
        class Vat
        {
            public static function add($amount) { return $amount * 1.2; }
        }
        PHP,
    'main.php' => <<<'PHP'
        <?php
        require 'vat.php';
        $amount = null;
        var_dump(Vat::add($amount));
        PHP,
], [
    'vat.php' => <<<'PHP'
        <?php
        class Vat
        {
            public static function add(int $amount): float { return $amount * 1.2; }
        }
        PHP,
]);

foreach (['before', 'after'] as $stage) {
    [$code, $output] = run("measure/parameter-type/$stage");
    echo $stage, ' exit=', $code, ' output=', $output, "\n";
}
```

```
parameter-type     files=1  exit=255 output changed -> breaking
before exit=0 output=float(0)
after exit=255 output=
```

**OM26.** Before the change, `null * 1.2` quietly gave `float(0)`; once the type was added, `null` could not be converted to an `int`, so a `TypeError` arose and the exit code became 255. The error was heard at execution time, with the consumer file unchanged. The lesson's first reach is **breaking**.

## The Bounding Measurement

Does the break come from adding a type declaration, or from the added type rejecting `null`? Making the same change with `?int` separates the second.

```php
<?php
// nullable.php — the same change, this time with ?int
require_once 'reach.php';

measure('nullable', [
    'vat.php' => <<<'PHP'
        <?php
        class Vat
        {
            public static function add($amount) { return $amount * 1.2; }
        }
        PHP,
    'main.php' => <<<'PHP'
        <?php
        require 'vat.php';
        $amount = null;
        var_dump(Vat::add($amount));
        PHP,
], [
    'vat.php' => <<<'PHP'
        <?php
        class Vat
        {
            public static function add(?int $amount): float { return $amount * 1.2; }
        }
        PHP,
]);

foreach (['before', 'after'] as $stage) {
    [$code, $output] = run("measure/nullable/$stage");
    echo $stage, ' exit=', $code, ' output=', $output, "\n";
}
```

```
nullable           files=1  exit=0   output same    -> in-place
before exit=0 output=float(0)
after exit=0 output=float(0)
```

**OM27.** With `?int`, `null` still passed and `float(0)` stayed the same: a type declaration could be an in-place change. What broke was the type rejecting `null`. Another kind of input is the numeric string: in a file that does not use `strict_types`, a numeric string such as `'5'` is coerced to an `int` parameter.

```php
<?php
// numeric_string.php — the consumer passes '5', its file does not use strict_types
require_once 'reach.php';

measure('numeric-string', [
    'vat.php' => <<<'PHP'
        <?php
        class Vat
        {
            public static function add($amount) { return $amount * 1.2; }
        }
        PHP,
    'main.php' => <<<'PHP'
        <?php
        require 'vat.php';
        var_dump(Vat::add('5'));
        PHP,
], [
    'vat.php' => <<<'PHP'
        <?php
        class Vat
        {
            public static function add(int $amount): float { return $amount * 1.2; }
        }
        PHP,
]);

foreach (['before', 'after'] as $stage) {
    [$code, $output] = run("measure/numeric-string/$stage");
    echo $stage, ' exit=', $code, ' output=', $output, "\n";
}
```

```
numeric-string     files=1  exit=0   output same    -> in-place
before exit=0 output=float(6)
after exit=0 output=float(6)
```

**OM28.** `'5'` became `5` through coercion, and the result stayed `float(6)` both before and after: the consumer is in place. So adding a type stops the consumer only on the inputs the type rejects; it does not reach the inputs it accepts or can coerce. The breaking results measured in this course were heard both at declaration and at call time, and at which stage they are heard is the subject of M11/K01; the course measures not the stage but whether the change reaches the consumer.

## Summary

- Parameter, return, and property types limit the point at which a value is accepted; `?int` is `int` or `null`, and `int|string` is one of two types.
- When `int` was added to the untyped `add($amount)` method, the consumer that passes `null` got exit code 255 instead of `float(0)`; the lesson's reach is breaking.
- When the same change was made with `?int`, `null` still passed and the output stayed `float(0)`: in place.
- If the consumer passes `'5'` and its file is not in strict mode, adding the type stayed in place because of coercion, with output `float(6)`.
- Whether adding a type reaches the consumer depends on the inputs the type rejects.

## Next Step

As classes, interfaces, traits, constants, magic methods, and types spread from one file to many, name collisions become unavoidable. The next lesson, Namespaces, asks how importing with `use` and changing a namespace affect the consumer.
