Что PHP называет типом

Тип в PHP — это не только подсказка для IDE. Это часть контракта: какие значения можно передать в функцию, что она возвращает, какие значения может хранить свойство класса или константа класса. Проверка типов в PHP динамическая: многое выясняется во время выполнения, но сами объявления типов парсятся и частично проверяются раньше.

Базовые скалярные типы: bool, int, float, string. Писать нужно именно так: boolean, integer и похожие псевдонимы в type declaration не работают как ожидаемый скалярный тип. Кроме них есть array, object, callable, iterable, null, false, true, mixed, void, never, классы, интерфейсы и enum. Про устройство массивов как отдельного типа важно читать вместе с \Массивы как ordered map\, потому что PHP-массив — не просто «список».

<?php

declare(strict_types=1);

function formatPrice(int $cents, string $currency): string
{
    return number_format($cents / 100, 2) . ' ' . $currency;
}

echo formatPrice(1299, 'EUR'); // 12.99 EUR

Здесь объявлены типы двух параметров и тип результата. Если вызов нарушит контракт, PHP выбросит TypeError. Это уже пересекается с \Ошибки, исключения и Throwable\: ошибка типа — не доменное «товар не найден», а ошибка программы или границы ввода.

Quick recall
Что произойдёт, если функция объявлена как `formatPrice(int $cents, string $currency): string`, а вызов нарушит типы параметров?

Где можно объявлять типы

Типы ставят у параметров, return value, свойств класса и, начиная с PHP 8.3, у констант класса. В обычном коде чаще всего видны первые два случая:

<?php

function userDisplayName(array $user): string
{
    return trim($user['first_name'] . ' ' . $user['last_name']);
}

Но array $user говорит только «сюда нужен массив». Он не говорит, что внутри есть ключи first_name и last_name. Для такой формы нужны PHPDoc и статический анализ: например, @param array{first_name: string, last_name: string} $user. Это не рантайм-проверка PHP, но практический способ описывать более точные структуры. См. \PHPDoc, generics и статический анализ\.

С объектами контракт обычно читается лучше:

<?php

final class UserName
{
    public function __construct(
        public readonly string $first,
        public readonly string $last,
    ) {}
}

function userDisplayName(UserName $name): string
{
    return trim($name->first . ' ' . $name->last);
}

Объектная модель, видимость и readonly свойства подробнее раскрываются в \Классы, объекты и видимость\.

Quick recall
Почему `array $user` в параметре не заменяет PHPDoc shape вроде `array{first_name: string, last_name: string}`?

null, nullable и union types

null — отдельный тип и отдельное значение: «значения нет». Если функция может вернуть строку или отсутствие значения, это лучше писать явно:

<?php

function findEmailById(int $id): ?string
{
    if ($id === 42) {
        return 'ada@example.com';
    }

    return null;
}

?string — короткая форма string|null. Она работает только для одного базового типа. Если вариантов больше, используют union type через |:

<?php

function normalizeId(int|string $id): string
{
    return trim((string) $id);
}

Union type полезен на границах системы: данные из формы, JSON, query string, старого API. Но если union расползается по бизнес-логике, это часто сигнал, что модель ещё не оформлена. Условия, сравнения и match, которые обычно идут рядом с такими ветками, см. в \Операторы и управляющие конструкции\.

Quick recall
В PHP `?string` — это короткая запись для чего?

mixed, void и never

mixed означает «может быть что угодно»: object|resource|array|string|float|int|bool|null. Это честнее, чем отсутствие типа, но не точнее. Хорошее место для mixed — низкоуровневый декодер, логгер, универсальный контейнер, адаптер к легаси-коду. Плохое место — основная доменная функция, где автор просто не решил, что она принимает.

<?php

function logValue(mixed $value): void
{
    error_log(var_export($value, true));
}

void у return type означает: функция ничего полезного не возвращает. Она может делать сайд-эффект: записать лог, отправить письмо, изменить состояние объекта.

never строже: функция вообще не возвращает управление. Она либо бросает исключение, либо вызывает exit, либо уходит в бесконечный цикл.

<?php

function fail(string $message): never
{
    throw new RuntimeException($message);
}

never бывает полезен для маленьких guard-функций: после вызова такой функции анализатор и человек понимают, что нормального продолжения нет.

Intersection и DNF types

Union type говорит «одно из»: A|B. Intersection type говорит «одновременно всё»: A&B. В PHP intersection работает для class-types: классов и интерфейсов.

<?php

function dumpCountableIterator(Iterator&Countable $items): void
{
    echo count($items) . PHP_EOL;

    foreach ($items as $item) {
        var_dump($item);
    }
}

Здесь объект должен быть и Iterator, и Countable. Это не «массив или итератор», а именно объект, удовлетворяющий обоим контрактам. Связанные темы — \SPL, итераторы и коллекции\, \Наследование, интерфейсы и трейты\ и \Функции, замыкания и callable\.

DNF types появились для случаев, где нужно сочетать union и intersection. Запись выглядит так:

<?php

function handle((Iterator&Countable)|array $items): void
{
    foreach ($items as $item) {
        var_dump($item);
    }
}

Это читается как «либо объект, который одновременно Iterator и Countable, либо массив». DNF не стоит применять ради демонстрации силы типовой системы. Если сигнатура становится тяжёлой, иногда лучше ввести интерфейс или адаптер.

flowchart TD A["Вызов функции с объявленными типами"] --> B{"В файле вызова есть declare(strict_types=1)?"} B -->|"Да"| C["Скалярные аргументы проверяются строго"] B -->|"Нет"| D["PHP пробует привести скалярные аргументы"] C --> E{"Тип аргумента совпал?"} D --> F{"Значение можно привести к нужному типу?"} E -->|"Да"| G["Функция выполняется"] E -->|"Нет"| H["TypeError"] F -->|"Да"| G F -->|"Нет"| H G --> I["Проверяется объявленный return type"] I -->|"Значение подходит"| J["Результат возвращается"] I -->|"Нарушен контракт"| H
flowchart TD
    A["Вызов функции с объявленными типами"] --> B{"В файле вызова есть declare(strict_types=1)?"}
    B -->|"Да"| C["Скалярные аргументы проверяются строго"]
    B -->|"Нет"| D["PHP пробует привести скалярные аргументы"]
    C --> E{"Тип аргумента совпал?"}
    D --> F{"Значение можно привести к нужному типу?"}
    E -->|"Да"| G["Функция выполняется"]
    E -->|"Нет"| H["TypeError"]
    F -->|"Да"| G
    F -->|"Нет"| H
    G --> I["Проверяется объявленный return type"]
    I -->|"Значение подходит"| J["Результат возвращается"]
    I -->|"Нарушен контракт"| H
`strict_types` влияет на проверку скалярных аргументов в месте вызова; объявленный тип результата тоже проверяется при возврате.

Что реально делает strict_types

По умолчанию PHP старается приводить скалярные значения к ожидаемому типу, если может. Например, функция ждёт int, а ей передали "10"; в coercive-режиме PHP может превратить строку в число. declare(strict_types=1) меняет это поведение, но только для вызовов, написанных в этом файле.

<?php

// lib.php
function double(int $value): int
{
    return $value * 2;
}
<?php

// strict-caller.php
declare(strict_types=1);

require __DIR__ . '/lib.php';

echo double('10'); // TypeError
<?php

// weak-caller.php
require __DIR__ . '/lib.php';

echo double('10'); // int(20)-подобное поведение: строка приводится к int

Ключевой факт: решает файл вызова, а не файл объявления функции. Поэтому strict_types=1 не «включает строгий режим во всём проекте». Его обычно ставят в начале каждого PHP-файла проекта, сразу после <?php, как было показано в \Синтаксис, теги и подключение файлов\.

Есть ещё одна тонкость: strict_types относится к скалярным объявлениям. Класс User не станет «более строгим» от этой директивы: объект либо является экземпляром нужного класса/интерфейса, либо нет. Также int допускается там, где ожидается float: это осознанное исключение, потому что целое число безопасно представимо как число с плавающей точкой.

Типичные ловушки

Первая ловушка — думать, что типы PHP заменяют валидацию. Они не проверят, что строка является email, что число положительное, что массив содержит нужные ключи. Для входных данных всё равно нужны фильтрация и валидация; см. \GET, POST и фильтрация ввода\.

Вторая — писать array там, где нужен объект или хотя бы PHPDoc shape. array быстро становится контейнером для всего подряд. Третья — использовать mixed как способ не принимать решение. Иногда это честно, но в прикладной логике точный тип почти всегда дешевле, чем будущая отладка.

Четвёртая — путать void и never. void возвращает управление без значения. never не возвращает управление вообще.

См. также

Sources

  1. PHP Manual: Type declarations
  2. PHP Manual: Type System
  3. PHP Manual: declare
  4. PHP Manual: Never
  5. PHP Manual: Mixed