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