skip to content

Union, Nullable & DNF

Combining types in a declaration: ?T, union and intersection types, DNF forms like (A&B)|null, and mixed. Interviewers use them to see whether you can express a precise contract natively.

part ofPHPoverview, primer and where to startread it →
on this pageshow

explore

questions

5

In PHP, what does a nullable type such as ?Customer mean, and is it the same as Customer|null or an optional parameter?

level: juniorimportance: must knowfreq 62%

answer

  1. question mark before one type
  2. ?T and T|null are identical
  3. nullable is not optional
  4. return null; explicitly, not return;
  5. T $x = null deprecated in 8.4

basics

~20 s

?Customer accepts a Customer object or null, and is exactly the same type as Customer|null. Nullable does not mean optional: the argument must still be passed unless the parameter also has a default, written ?Customer $c = null.

solid answer

~40 s

A leading `?` (PHP 7.1) marks a **single** type as nullable: `?Customer` accepts a `Customer` or `null`, and the manual states that `?T` and `T|null` are identical. The `?` cannot prefix a union — `?int|string` is a syntax error, so you write `int|string|null`. **Nullable is not optional**: `function assign(?Customer $c)` still requires an argument, and calling it with none throws `ArgumentCountError`; `?Customer $c = null` is both. On a return type, `?Customer` requires an explicit `return null;` — a bare `return;` does not compile, and falling off the end throws `TypeError`. The old shortcut `Customer $c = null`, which made the type nullable implicitly, is deprecated since PHP 8.4.

code

php · 20 lines
php
<?php
declare(strict_types=1);

final class Customer
{
    public function __construct(public string $name) {}
}

function greet(?Customer $customer): string   // nullable, but required
{
    return 'Hello, ' . ($customer?->name ?? 'guest');
}

echo greet(null), PHP_EOL;                    // Hello, guest

try {
    echo greet();
} catch (ArgumentCountError $e) {
    echo 'missing argument', PHP_EOL;         // nullable is not optional
}

go deeper

for a junior

Recall that ?Customer accepts a Customer or null, that it equals Customer|null, and that you still must pass the argument unless there is a default.

for a middle

Explain why ? works on one type only, the explicit return null rule for nullable returns, and why PHP 8.4 deprecated the implicit T $x = null form.

for a senior

Decide where null is a legitimate result and where absence should throw, since every nullable return pushes a check onto every caller.

for a principal

Set a codebase convention for representing absence — nullable returns, exceptions or dedicated result objects — and keep it consistent across modules.

## What the question mark does In a PHP type declaration, a leading `?` means **"this type, or null"**. It was added in PHP 7.1, before PHP had general union types: ```php <?php declare(strict_types=1); function describe(?Customer $customer): string { return $customer === null ? 'guest' : $customer->name; } ``` `describe(new Customer(...))` and `describe(null)` both work; `describe('bob')` throws `TypeError`. ## ?T and T|null are the same type PHP 8.0 added union types, and with them a second spelling. The manual says it directly: `?T` and `T|null` are identical. Reflection, error messages and the engine treat them as one type. Which spelling to use is style: - `?Customer` is shorter and the common choice for one type plus null. - `int|string|null` is **required** when more than one non-null type is involved, because `?` applies only to a single type. `?int|string` does not parse. - For an intersection plus null, PHP 8.2+ needs the DNF form `(A&B)|null`; `?` cannot prefix an intersection either. Two spellings are rejected outright: `?mixed` (mixed already includes null) and `?null`. ## Nullable is not optional This is the part candidates most often blur. **Nullable** is about which values are accepted; **optional** is about whether the argument may be omitted. They are independent: | Declaration | `f(null)` | `f()` | |---|---|---| | `function f(?Customer $c)` | accepted | `ArgumentCountError` | | `function f(?Customer $c = null)` | accepted | accepted, `$c` is `null` | | `function f(Customer $c)` | `TypeError` | `ArgumentCountError` | | `function f(Customer $c = null)` | accepted (declaration deprecated since 8.4) | accepted, `$c` is `null` | The last row is the old implicit form: a `null` default silently widened the type to nullable. PHP 8.4 deprecated it because the declared type no longer says what the parameter accepts. The fix is to write the `?` explicitly. ## Nullable return types On a return type, `?Customer` means the function returns a `Customer` or `null` — but it must **say** which: 1. `return null;` is fine. 2. A bare `return;` is a compile error: `A function with return type must return a value (did you mean "return null;" instead of "return;"?)`. 3. Reaching the end of the body without any `return` throws `TypeError` at runtime (`Return value must be of type ?Customer, none returned`). PHP does not treat "no return" as "return null" for a declared return type. The rule exists to separate a deliberate "not found" from a forgotten return. ## When to use a nullable type - **Lookups that can miss**: `findCustomer(int $id): ?Customer` returns `null` for "no such customer". The caller must handle it, and the nullsafe operator `?->` or an explicit check makes that visible. - **Truly optional inputs**: `?string $note = null` for an argument that may be absent. - **Avoid it where absence is an error.** If a missing customer is a bug, return `Customer` and throw; a nullable return pushes a check onto every caller. ## Handling a nullable value Once a value may be `null`, the code that receives it has to deal with that before using it as an object: - an explicit check: `if ($customer === null) { ... }`; - the **nullsafe operator** (PHP 8.0): `$customer?->name` evaluates to `null` instead of throwing when `$customer` is `null`; - the **null coalescing operator**: `$customer?->name ?? 'guest'` supplies a fallback. Calling a method directly on a `null` value, `$customer->name()`, throws an `Error`. That is the practical cost of a nullable type: every consumer carries a branch, which is why it should mark genuine absence rather than serve as a convenient default. ## Common mistakes 1. Treating `?Customer $c` as optional and then calling the function without an argument. 2. Writing `?int|string` and getting a syntax error, instead of `int|string|null`. 3. Returning with a bare `return;` from a `?Customer` function, which does not compile. 4. Keeping `Customer $c = null` in new code, which raises a deprecation on PHP 8.4 and later. ## Quick reference - `?T` = `T|null`, since PHP 7.1; the union spelling since 8.0. - `?` works on one type only. - Nullable ≠ optional; add `= null` for optional. - `T $x = null` without `?` is deprecated since PHP 8.4. - A `?T` return needs an explicit `return null;`.

  • In PHP, how do you declare a parameter that accepts an int, a string or null?
    Write the union with `null` as a member: `int|string|null $key`. The `?` shorthand applies to one type only, so `?int|string` is a syntax error. If the argument may also be omitted, add a default: `int|string|null $key = null`.
  • In PHP, what happens when a function declared : ?Customer reaches the end of its body without a return statement?
    It throws `TypeError` saying the return value must be of type `?Customer` and none was returned. A declared return type is never satisfied by an implicit return, even when `null` is allowed. Write `return null;` for the not-found path; a bare `return;` would not compile.

saying these in an interview costs you the question

  • Says ?Customer makes the parameter optional, so it can be left out.
  • Thinks ?Customer and Customer|null behave differently at runtime.
  • Writes ?int|string for an int, a string or null.
  • Uses a bare return; to return null from a function declared ?Customer.
  • Recommends Customer $c = null as the normal way to declare a nullable parameter.
open as a page

In PHP 8, what does mixed accept, how does it differ from no type, and what are the null, false, true, iterable and callable types?

level: middleimportance: should knowfreq 30%

basics

~20 s

mixed (PHP 8.0) accepts every value including null, like an untyped parameter, but states the intent explicitly and, as a return type, requires an actual return. null, false and true are literal types usable alone since 8.2; iterable means array|Traversable; callable cannot type a property.

open as a page

In PHP 8, how do you type a CRM function taking a customer id or a Customer object, and what rules govern union types?

level: middleimportance: should knowfreq 44%

basics

~20 s

Declare int|Customer $customer: a union type (PHP 8.0) accepts a value matching any member, and the body narrows it with instanceof. Unions reject duplicate or trivially redundant members at compile time and cannot contain mixed, void or never.

open as a page

PHP 8.4 deprecated parameters like Customer $customer = null; why, and how do you clean them up across a large CRM codebase?

level: seniorimportance: should knowfreq 28%

basics

~20 s

A null default used to make the type nullable silently, so Customer $customer = null accepted null while the type said Customer. PHP 8.4 deprecates that; fix each site by writing ?Customer or Customer|null explicitly, found with a static scan rather than runtime logs.

open as a page

In PHP 8.2+, what does a DNF type such as (HasEmail&HasConsent)|null express, and which intersection and DNF forms does PHP reject?

level: seniorimportance: nice to knowfreq 16%

basics

~20 s

A&B, an intersection type (PHP 8.1), accepts only objects that are instances of every listed class or interface. DNF types (PHP 8.2) combine them with unions as an OR of parenthesised ANDs, so (HasEmail&HasConsent)|null means an object implementing both interfaces, or null.

open as a page