<? phpukraine СПІВБЕСІДИ
Пошук по платформі
CORE PHP · MIDDLE ЧАСТО ПИТАЮТЬ

Union, intersection, nullable, mixed і never: як працює система типів PHP 8?

PHP 8.0 дав union (`int|string`), `mixed` як верхній тип і `static` як тип повернення; 8.1 додав `never` і intersection (`A&B`); 8.2 дозволив DNF - комбінації виду `(A&B)|null`. Повернення в нащадку можна звужувати, параметри розширювати, типи властивостей мають лишатися незмінними.

Чим `?int` відрізняється від `int|null`?
Без `strict_types` у функцію з типом `int|string` передали `true`. Що прийде всередину?
Навіщо `never`, якщо вже є `void`?
Чому `?Countable&Traversable` не компілюється, а в 8.2 щось схоже раптом працює?
types union intersection never mixed variance DNF

Union-тип перелічує допустимі типи через |, а перевірка спрацьовує в рантаймі при кожному виклику. Найчастіше валяться на нестрогому режимі: PHP не пробує типи в тому порядку, у якому ви їх написали. Спершу шукається точний збіг, а якщо його немає, конверсія йде фіксованим порядком int, float, string, bool, і типи, відсутні в союзі, просто пропускаються. Через це int|string перетворить true на 1, а рядок "42" залишить рядком. У строгому режимі союз працює як звичайна перевірка на точний збіг з єдиною поблажкою: int приймається там, де оголошено float. Nullable-синтаксис окремою сутністю не є: ?Article компілюється в той самий union, що й Article|null, і рефлексія віддасть ReflectionUnionType. Змішувати обидві форми в одному оголошенні заборонено, тому ?Article|Draft не парситься.

Союз доречний там, де набір варіантів маленький, фіксований і зрозумілий викликачу: ідентифікатор int|string, значення string|Stringable, обробник Closure|string, відсутність результату через |null. Запахом він стає в трьох випадках. Коли типів більше трьох і кожен викликач змушений писати драбину з instanceof, у коді бракує спільного інтерфейсу. Коли в союз потрапляє false як позначка помилки (Report|false), перевірка === false розповзається по всіх шарах, хоча помилку природніше описує виняток або окремий тип результату. І коли союз збирає непов'язані доменні типи, щоб зекономити на одному методі: така економія обертається розгалуженням у кожному місці використання. Перевірте себе просто: якщо після отримання значення ви одразу питаєте «а що ж воно таке», союз узятий замість поліморфізму.

Intersection з 8.1 розв'язує зворотну задачу: вимагає від значення відповідати кільком контрактам одночасно. HasId&Timestamped у прикладі позбавляє потреби створювати порожній інтерфейс-склейку HasIdAndTimestamped тільки заради однієї сигнатури. Обмеження жорсткі: у перетині дозволені виключно класи та інтерфейси, ніяких скалярів, array, callable, iterable, null, self, static чи parent. Інакше й бути не могло, бо int&string не мав би жодного значення. Мінус підходу теж реальний: перетин розмазує контракт по сигнатурах, і коли одна й та сама пара інтерфейсів зустрічається в десятому місці, окремий іменований інтерфейс уже читається краще. PHP 8.2 зняв найдошкульніше обмеження першої версії, дозволивши DNF-типи: (Countable&Traversable)|array або (A&B)|null. Кожен перетин у союзі обгортається дужками обовʼязково, і саме так тепер пишеться nullable-перетин.

Варіантність тримається на двох правилах і одному винятку. Тип повернення коваріантний: нащадок звужує обіцянку, тому ArticleRepository::all() віддає array там, де база оголосила iterable, і код, написаний під абстрактний клас, від цього не постраждає. Тип параметра контраваріантний: нащадок може приймати ширше, тому forget(HasId|Timestamped $entity) сумісний з батьківським forget(HasId $entity), а от звуження до конкретного класу дало б фатальну помилку. Виняток - властивості: їхні типи інваріантні, бо властивість і читають, і пишуть, отже будь-яка зміна ламає один із двох боків. never і mixed замикають цю ієрархію: перше є нижнім типом і підходить як повернення на місце будь-якого іншого, друге - верхнім, тому його не можна ні зробити nullable, ні додати в союз.

Межі системи типів краще проговорювати вголос, бо саме звідти беруться реальні проблеми. Generics у PHP немає, тому array у сигнатурі не говорить нічого про елементи: list<Article> живе тільки в докблоці й перевіряється PHPStan або Psalm, а в рантаймі всередину масиву ніхто не заглядає. Рантайм-перевірка союзу коштує дешево, але вона поверхнева, і на межі з зовнішніми даними все одно потрібна валідація. Рефлексія над складними типами вимагає обробляти три класи: ReflectionNamedType, ReflectionUnionType, ReflectionIntersectionType, причому DNF повертає union, всередині якого лежать intersection-вузли. Контейнери впровадження залежностей на union-параметрах здебільшого пасують: автовайринг не вміє вибирати між Redis|Memcached, і такий аргумент доведеться налаштовувати руками.

declare(strict_types=1);

interface HasId { public function id(): int; }
interface Timestamped { public function updatedAt(): DateTimeImmutable; }

// intersection: аргумент має реалізувати обидва інтерфейси одразу
function cacheKey(HasId&Timestamped $entity): string
{
    return $entity->id().':'.$entity->updatedAt()->getTimestamp();
}

// DNF з 8.2: або пара інтерфейсів, або звичайний масив; дужки обовʼязкові
function itemsCount((Countable&Traversable)|array $items): int
{
    return count($items);
}

// never: керування не повертається, тому код після виклику недосяжний
function fail(string $message): never
{
    throw new DomainException($message);
}

abstract class Repository
{
    abstract public function all(): iterable;        // мінімальна обіцянка

    abstract public function forget(HasId $entity): void;
}

final class ArticleRepository extends Repository
{
    public function all(): array                     // коваріантність: вужче за iterable
    {
        return [];
    }

    public function forget(HasId|Timestamped $entity): void  // контраваріантність
    {
        fail('видалення ще не реалізоване');
    }
}
Що `?T` - це рівно `T|null`, і що змішати знак питання з вертикальною рискою не можна: `?A|B` не парситься.
Що в нестрогому режимі union не пробує типи зліва направо, а йде фіксованим порядком int, float, string, bool, пропускаючи ті, яких у союзі немає.
Що intersection працює лише з класами та інтерфейсами: `int&string` або `Countable&array` - помилка компіляції, а не хитрий тип.
Напрямки варіантності: тип повернення в нащадку можна звузити, тип параметра розширити, тип властивості змінити не можна взагалі.
Що `never` - нижній тип (підходить як повернення на місце будь-якого іншого), а `mixed` - верхній, тому `?mixed` і `mixed|int` заборонені як надлишкові.
Вважати `?T` окремою конструкцією зі своєю семантикою: рефлексія покаже звичайний `ReflectionUnionType` з `null` усередині.
Розширювати тип повернення в нащадку (батько обіцяв `array`, нащадок віддає `iterable`) і не розуміти фатальної помилки про несумісну сигнатуру.
Ставити `never` параметром або типом властивості: воно дозволене тільки як тип повернення функції чи методу.
Повертати `Report|false` замість винятку і тягнути перевірку `=== false` через усі шари, хоча помилку мав би описувати виняток або окремий тип результату.
Думати, що `array|null` у сигнатурі щось гарантує про вміст масиву: типів елементів у рантаймі немає, це рівень PHPStan і докблоків.
ПОРАДА

Розкладіть відповідь по осях: union - «або те, або те» з перевіркою в рантаймі, intersection - «і те, і те» тільки для обʼєктів, DNF з 8.2 склеює обидва в одному оголошенні, а `never` і `mixed` замикають ієрархію знизу і зверху. Наприкінці додайте фразу про варіантність: «повертати можна вужче, приймати ширше, властивості - точно так само». Це одразу показує, що ви писали бібліотечний код, а не лише споживали чужі інтерфейси.

оновлено 9 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

Nullable-перетин пишеться лише в DNF-формі з обовʼязковими дужками, і саме її додали в PHP 8.2. `?A&B` заборонений граматикою, intersection зі скалярів неможливий, бо `int&string` не має жодного значення, а `mixed` уже включає все, тому будь-який союз з ним PHP відхиляє як надлишковий.