Skip to content
academia.sh

Lesson 07 / 12

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.

Published Updated

In this lesson

  • 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
Contents

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
// 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
// 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
// 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
// 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
// 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
// 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
// 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.

# 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
// 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
// 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
// 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
// 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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close