Union, Nullable, and Intersection Types: Widening the Contract
A single-type signature tells callers one thing. Real inputs are messier: a route parameter arrives as a string but your logic wants an int, a flag is true, false, or absent. PHP 8.0's union types express exactly that:
function lookup(string|int $id): array
{
return matches(is_int($id)
? ['where' => 'id', 'value' => $id]
: ['where' => 'slug', 'value' => $id]);
}
string|int means the parameter accepts a value of either declared type — and coercive typing still applies per member ('42' becomes int 42 for an int branch where sensible), while strict mode demands the value already match one member exactly.
Three rules keep unions honest:
- Order is irrelevant —
int|stringandstring|intare the same type. - Duplicates are a compile error —
int|intis rejected as redundant; the compiler catches the copy-paste. falseandtruejoin unions only —boolremains the shorthand fortrue|false, but you can writearray|falseto say "an array, or a failure", which is exactly whatarray_search,explode, and friends return. Since PHP 8.2falseand evennullmay stand alone as types for symmetry.
The nullable shorthand deserves a note: ?int is precisely int|null — same type, same behavior, different spelling. Writing int|null in signatures where absence is a meaningful outcome (a not-found result) reads better to some codebases; either is correct, and mixing them in one file is the only real sin.
At the other pole sits mixed (PHP 8.0): it accepts everything, implies nullable, and cannot be combined with anything else — mixed|null is a compile error because mixed already includes null. mixed is an honest type for a deliberately generic boundary (a logger's context value, a JSON payload field), and a smell anywhere else: it restores the "no contract at all" problem the typed signatures exist to solve.
PHP 8.1 added intersection types for the opposite problem — requiring several capabilities at once:
function drain(Countable&Iterator $source): void { … }
The argument must implement both Countable and Iterator; intersections are object-only, so they sharpen interfaces rather than widen them. PHP 8.3 completed the algebra with DNF (disjunctive normal form) types that mix both, with parentheses required around the intersection:
function export((Countable&Iterator)|false $source): void { … }
Finally, two return-only forms round out the vocabulary you will meet in the object topics: self for "this class", and static (PHP 8.0) for late static binding — a method declared : static on a base class returns the subclass instance, which is how fluent APIs stay chainable after inheritance. Knowing that static exists is enough here; using it well is an OOP discussion.
PHP 8.1's enums plugged into this algebra too: an enum case can be a type in its own right, so a status parameter is neither an unvalidated string nor a vague int code:
enum ShipmentState: string { case Packed = 'packed'; case InTransit = 'in_transit'; }
function label(ShipmentState $state): string { … }
label(ShipmentState::Packed); // the only legal values are declared cases
label('packed'); // TypeError — strings no longer sneak in
Combined with match exhaustiveness from the control-flow topic, enum types close off an entire bug class: invalid states stop existing at the type level rather than being filtered in every handler.
Coercive vs Strict: How '42' Becomes 42, and When It Becomes a TypeError
Every typed call in PHP passes through one of two regimes, and which one applies is decided by strict_types in the file where the call is written — the per-call-site rule from Variables and Scalar Types, now applied to every type in the language.
// caller.php — no declare()
discount(19.99, '20'); // coerced: '20' → 20 (unless strict_types=1 in THIS file)
What coercion does across the scalar types:
| Parameter type | Coercive mode accepts | Strict mode accepts |
|---|---|---|
int |
'42' → 42, 42.0 → 42, true → 1 |
only int |
float |
'3.5' → 3.5, 42 → 42.0 |
only float |
string |
42 → '42', true → '1' |
only string |
bool |
0→false, 1→true, ''/'0' → false, other strings → true |
only bool |
int|string |
per-branch coercion of the same kind | exactly int or string |
The line that fails in both regimes is the one with no numeric content: 'apples' to an int parameter throws TypeError even under coercion, because there is nothing to convert. And lossy conversions — a float-string like '42.9' into int — are the dangerous middle ground: coercion can accept them and silently truncate, which is exactly the class of bug strict mode exists to kill.
The error message is a debugging tool in its own right:
TypeError: discount(): Argument #2 ($percent) must be of type int,
string given, called in checkout.php on line 18
Read it right-to-left: the call site is named, not the function body. When a TypeError surprises you in production, that line number is where the wrong value entered, and no stack trace digging is required.
Since PHP 8.0, internal functions follow the same contract and behave consistently: strlen([]) is a TypeError (it was a warning plus null in 7.x), and internal functions with bool parameters treat only '' and '0' as false — so str_contains($haystack, $needle) with 0 as a needle no longer does something accidental. Userland code that still runs on 7.4 cannot rely on any of this, which is the version-sensitivity theme from the install topic showing up again in a new place.
The type vocabulary also holds three members that predate unions but now read as shorthands for them. iterable is exactly array|Traversable — the right parameter type for anything a foreach can consume, and the reason APIs accept generators without saying so. callable accepts a closure, function name, or array form and deliberately refuses to join unions (the engine cannot reliably type-check a union of callables). object accepts any object with no class specified — useful at generic boundaries, a signal to type it precisely once you know the class.
The policy that reconciles both regimes: coerce at the boundary, stay strict inside. Untrusted input is strings — form fields, query parameters, route segments — so convert and validate once where it enters (filter_var($v, FILTER_VALIDATE_INT), which itself returns int|false, a union return in the wild), then let every internal signature be exact. Code that reaches for coercion inside a codebase is usually compensating for a value that should have been converted at the door.
Typing in Practice: Contracts That Fail Loudly at the Right Line
Declarations are only syntax until they change how code is written. Four patterns show the payoff.
Start with a signature as it often appears in legacy code, and the same function after typing:
// before — every failure mode deferred to the caller
function findOrders($user, $since = null) { … }
// after — the contract states exactly what may arrive and what may leave
function findOrders(User $user, ?DateTimeImmutable $since = null): iterable { … }
The typed version does more than document: constructor validation now happens at the call boundary, IDEs autocomplete the parameter, and a wrong argument fails at the line it entered instead of producing a confusing error three frames deeper. The rest of this topic is that idea applied to the sharp edges.
Union at the boundary, single type inside. HTTP gives you strings; logic wants precise types. Normalize once:
function show(string $idParam): Response
{
$id = filter_var($idParam, FILTER_VALIDATE_INT);
if ($id === false) {
return Response::badRequest('id must be an integer');
}
return $this->renderProduct($id); // renderProduct(int $id) — exact
}
The signature of the inner function can then be a plain int — no union, no coercion, no doubt — because the messy shape was absorbed at the edge.
void catches the accidental return. A helper that builds a string and then forgets which variable it meant to hand back:
function renderHeader(string $title): void
{
$html = '<h1>' . htmlspecialchars($title) . '</h1>';
echo $html;
return $html; // TypeError: renderHeader(): void function must not return a value
}
Without the declaration, that stray return silently yields null to any caller who might try to use it. With void, the mistake dies at the exact line it was written. That is the "catching accidental return statements" benefit — it costs one colon.
never for abort helpers. A function that always throws or exits gets : never, which both documents the behavior and lets analysis treat the code after any call as unreachable:
function abort(int $status, string $reason): never
{
throw new HttpException($status, $reason);
}
Test the contract, not just the happy path. Typed signatures make failure modes assertable:
public function testDiscountRejectsStrings(): void
{
$this->expectException(TypeError::class);
discount('free', 10); // test file declares strict_types
}
Note the detail: the test file itself must declare strict_types=1, because the assertion depends on this call site refusing coercion. The mirror-image test also earns its place during a migration: one case asserting that a legacy, non-strict caller still coerces (discount(19.99, '20') from a file without the declaration) pins the boundary contract while new code stays strict — you are testing the migration's behavior, not the implementation.
Static analysis closes the loop. PHPStan or Psalm at level 6+ treats declared types as verified facts: an impossible branch after a never call is flagged, an unhandled union member is flagged, a wrong argument type is flagged — all before the code runs, all pointing at the same lines the runtime would have. What ships to production is then a codebase where type errors are a build-time concern and a loud, precisely-located runtime failure — never a silent null three frames away.
Quiz
1. Which of these is a compile error in PHP 8?
2. A strict_types file calls `f('42')` where f declares `int $n`. What happens, and where is the error reported?
3. What changed in PHP 8.0 about internal functions and wrong argument types?
4. A helper has `: void` but ends with `return $html;`. What happens and why is that valuable?
Flashcards
Question
Syntax rules for union types
Click to reveal answer
Answer
Members may be in any order (int|string == string|int), duplicates are a compile error, false/true may only join a union (standalone since 8.2), and intersections (A&B, 8.1) are object-only with DNF forms like (A&B)|false legal since 8.3.
Question
?int vs int|null, and what mixed means
Click to reveal answer
Answer
?int is exactly int|null — identical semantics, different spelling. mixed (8.0) accepts every value, implicitly includes null, and cannot be combined with any other type; use it only at deliberately generic boundaries.
Question
Which file's strict_types setting decides coercion for a call?
Click to reveal answer
Answer
The file where the CALL is written — not the file where the function is defined. That is why a strict library still sees coercion from legacy callers, and why test files must declare strict_types=1 to assert TypeErrors.
Question
What does a TypeError message tell you, and why is that useful?
Click to reveal answer
Answer
It names the function, the numbered argument, the required type, the given type, and the file:line of the call site. Because the call site is where the wrong value entered, you can fix the boundary instead of digging through a stack trace.
Question
State the boundary-coercion design rule.
Click to reveal answer
Answer
Convert and validate untrusted input once at the boundary (filter_var with FILTER_VALIDATE_INT, which returns int|false), then keep every internal signature exact. Coercion deep inside a codebase is usually a symptom that conversion happened too late or not at all.
Revision Notes
Key Takeaways
- 1. Union types (8.0) express alternatives, intersections (8.1) express multiple capabilities, and DNF (8.3) combines both with parenthesized intersections
- 2. ?T is T|null; mixed accepts everything and cannot be combined; false joins unions to express failure returns like array|false
- 3. Coercive mode converts per the scalar table and still throws for impossible conversions; strict mode accepts only exact types
- 4. strict_types lives in the calling file and the TypeError message points at the call site — the exact place the bad value entered
- 5. Design: validate and coerce at the boundary, exact types inside; void and never make wrong control flow fail loudly and locally
Interview Tips
- • "Explain union types with an example and a gotcha" — int|string for route ids; gotcha: duplicate members are a compile error and false only joins unions; add DNF (A&B)|false for 8.3 depth
- • "Coercive vs strict typing — when does each apply?" — coercion is the default and follows the CALLING file's strict_types; give the '42' → 42 example and note internal functions throw TypeErrors since 8.0
- • "Why declare void?" — it converts a stray `return $x;` into a TypeError on that line instead of silently returning null; contrast with never for functions that always throw
- • "How do you keep type safety with untrusted input?" — validate/convert at the boundary (filter_var returning int|false), exact types downstream, and PHPUnit expectException(TypeError::class) tests that declare strict_types
Cheat Sheet
Return Types and Strict Typing
Type vocabulary (PHP 8.x):
function f(int|string $id): array|false { … } // union (8.0) — order irrelevant, duplicates = compile error
function g(?int $n): ?array { … } // ?T is exactly T|null
function h(Countable&Iterator $s): void { … } // intersection (8.1) — object-only
function i((Countable&Iterator)|false $s) {} // DNF (8.3) — parens around the intersection
function j(): static { … } // static return (8.0); self/parent also exist
mixed (8.0) accepts everything, includes null, and cannot be unioned with anything. false/true join unions (standalone since 8.2).
Coercion vs strict — decided by the CALLING file:
| Param | Coercive (default) | Strict (declare(strict_types=1)) |
|---|---|---|
int |
'42'→42, 42.0→42 |
exact int only |
float |
'3.5'→3.5, 42→42.0 |
exact float only |
string |
42→'42' |
exact string only |
bool |
0/1, ''/'0' falsy, other strings true | exact bool only |
| anything | 'apples'→ int still throws TypeError |
TypeError |
Error names the CALL SITE: must be of type int, string given, called in checkout.php on line 18. Internal functions since 8.0 throw TypeErrors consistently (strlen([])).
Patterns: coerce+validate at the boundary (filter_var($v, FILTER_VALIDATE_INT) → int|false), single exact types inside. : void turns an accidental return $x; into a TypeError on that line. : never marks always-throwing helpers. Test contracts with expectException(TypeError::class) — the test file must declare strict_types.