advanced php language

Union, Intersection, and DNF Types

PHP types are not only documentation. They are executable rules that stop the wrong kind of value entering a function, being stored on an object, or being returned from an API.

Union, intersection, and DNF types let you describe more realistic shapes than a single class or scalar. They are useful when a value may be one of a few valid forms, or when an object must satisfy more than one contract at the same time.

Union Types

A union type means a value may be any one of the listed types.

PHP example
<?php

declare(strict_types=1);

function formatIdentifier(int|string $id): string
{
    return 'ID-' . (string) $id;
}

echo formatIdentifier(42) . PHP_EOL;
echo formatIdentifier('A100') . PHP_EOL;

// Prints:
// ID-42
// ID-A100

This is better than accepting mixed when the real rule is narrower. The function does not accept arrays, booleans, objects, or null; it accepts only the two forms that make sense.

Unions are common at application boundaries. A route parameter may arrive as a string, while older internal code may already pass an integer. A repository method may return an object when it finds a record, or null when it does not.

PHP example
<?php

declare(strict_types=1);

final class User
{
    public function __construct(
        public int $id,
        public string $email,
    ) {
    }
}

function findUser(int $id): User|null
{
    if ($id !== 7) {
        return null;
    }

    return new User(7, 'ada@example.com');
}

$user = findUser(7);

echo $user?->email ?? 'not found';
echo PHP_EOL;

// Prints:
// ada@example.com

User|null is the long form of ?User. Use whichever is clearer for the surrounding code, but do not write both together.

The false Return Type

Some older PHP APIs return a useful value or false on failure. PHP allows that shape to be expressed directly.

PHP example
<?php

declare(strict_types=1);

function readConfigLine(string $path): string|false
{
    if (!is_file($path)) {
        return false;
    }

    $line = fgets(fopen($path, 'rb'));

    return $line === false ? false : trim($line);
}

$line = readConfigLine('/path/that/does-not-exist');

echo $line === false ? 'missing' : $line;
echo PHP_EOL;

// Prints:
// missing

For new domain code, prefer null, an exception, or a result object when that communicates failure more clearly. Use false mainly when wrapping or matching older PHP behaviour.

Intersection Types

An intersection type means a value must satisfy every listed type.

PHP example
<?php

declare(strict_types=1);

interface CanRender
{
    public function render(): string;
}

interface CanCache
{
    public function cacheKey(): string;
}

final class ProductCard implements CanRender, CanCache
{
    public function render(): string
    {
        return '<article>Keyboard</article>';
    }

    public function cacheKey(): string
    {
        return 'product-card:keyboard';
    }
}

function cacheRendered(CanRender&CanCache $component): string
{
    return $component->cacheKey() . ' => ' . $component->render();
}

echo cacheRendered(new ProductCard()) . PHP_EOL;

// Prints:
// product-card:keyboard => <article>Keyboard</article>

CanRender&CanCache says the function needs both behaviours. A class that can render but cannot provide a cache key is not enough.

Intersection types are useful around framework extensions, decorators, exporters, and service objects where the code needs a combination of contracts without caring about the concrete class name.

DNF Types

DNF stands for disjunctive normal form. In PHP, it lets you combine unions and intersections in a controlled way by grouping intersections inside a union.

PHP example
<?php

declare(strict_types=1);

interface CanExport
{
    public function export(): string;
}

interface HasFilename
{
    public function filename(): string;
}

final class CsvReport implements CanExport, HasFilename
{
    public function export(): string
    {
        return 'id,total';
    }

    public function filename(): string
    {
        return 'orders.csv';
    }
}

final class InlineReport
{
    public function __construct(
        public string $body,
    ) {
    }
}

function sendReport((CanExport&HasFilename)|InlineReport $report): string
{
    if ($report instanceof InlineReport) {
        return 'inline: ' . $report->body;
    }

    return 'attachment: ' . $report->filename() . ' contains ' . $report->export();
}

echo sendReport(new CsvReport()) . PHP_EOL;
echo sendReport(new InlineReport('No orders today')) . PHP_EOL;

// Prints:
// attachment: orders.csv contains id,total
// inline: No orders today

The type means: accept either an object that is both CanExport and HasFilename, or accept an InlineReport. The parentheses matter because they show which contracts belong together.

DNF types are less common than simple unions and intersections. When they appear, they should describe a real API boundary, not a clever puzzle.

Types Do Not Replace Validation

Type declarations answer "what kind of value is this?" They do not answer every business rule.

PHP example
<?php

declare(strict_types=1);

function applyDiscount(int|float $percentage): string
{
    if ($percentage < 0 || $percentage > 100) {
        throw new InvalidArgumentException('Discount must be between 0 and 100.');
    }

    return 'Discount: ' . $percentage . '%';
}

echo applyDiscount(15) . PHP_EOL;

// Prints:
// Discount: 15%

int|float prevents strings and arrays. The range check still belongs inside the function because 500 is a valid integer but not a valid discount.

Choosing The Right Type

Use a single concrete type when there is only one valid shape. Use a union when there are a few genuine alternatives. Use an intersection when the code needs several behaviours on the same object. Use a DNF type only when the allowed combinations would otherwise be unclear.

Avoid using broad types to hide design problems. If a function accepts string|int|array|object|null, it is probably doing too much or sitting at a boundary that needs parsing into clearer application objects.

For junior PHP work, the important skill is not memorising the terminology. It is being able to read a signature, know what values are allowed, and avoid weakening that signature when fixing a bug.

Practice

Practice: Model Allowed Report Inputs

Create a small report-sending example that uses union and intersection types deliberately.

Task

Build:

  • a CanExport interface with an export(): string method
  • a HasFilename interface with a filename(): string method
  • a CsvReport class that implements both interfaces
  • an InlineReport class that carries a body string
  • a sendReport() function that accepts (CanExport&HasFilename)|InlineReport

Use strict types. Show one attachment-style report and one inline report. Keep the expected output inside the PHP code block as printed lines or comments.

Afterward, add a short note explaining why this is better than accepting mixed.

Show solution

This solution uses an intersection for reports that must be exportable and named, then a union to allow a separate inline report shape.

PHP example
<?php

declare(strict_types=1);

interface CanExport
{
    public function export(): string;
}

interface HasFilename
{
    public function filename(): string;
}

final class CsvReport implements CanExport, HasFilename
{
    public function __construct(
        private string $filename,
        private string $contents,
    ) {
    }

    public function export(): string
    {
        return $this->contents;
    }

    public function filename(): string
    {
        return $this->filename;
    }
}

final class InlineReport
{
    public function __construct(
        public string $body,
    ) {
    }
}

function sendReport((CanExport&HasFilename)|InlineReport $report): string
{
    if ($report instanceof InlineReport) {
        return 'inline: ' . $report->body;
    }

    return 'attachment: ' . $report->filename() . ' contains ' . $report->export();
}

echo sendReport(new CsvReport('orders.csv', 'id,total')) . PHP_EOL;
echo sendReport(new InlineReport('No orders today')) . PHP_EOL;

// Prints:
// attachment: orders.csv contains id,total
// inline: No orders today

This is better than mixed because the function signature tells the next developer exactly which shapes are valid. PHP will reject unrelated values before the function tries to use methods that may not exist.