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;
?intisintornull, andint|stringis one of two types. - When
intwas added to the untypedadd($amount)method, the consumer that passesnullgot exit code 255 instead offloat(0); the lesson’s reach is breaking. - When the same change was made with
?int,nullstill passed and the output stayedfloat(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 outputfloat(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.