Новое в журнале. Маленькие pull request: почему их быстрее ревьюят и как резать задачу
PHP и бэкенд

Типизация в PHP: частые ошибки в type hints от PHP 7 до 8.4

Строгая типизация в PHP не защищает автоматически от плохого дизайна. Разбираем, где type hints используют неправильно: от слепого strict_types до чрезмерно широких union-типов.

16 декабря 20259 мин чтенияПрактикам
Типизация в PHP: частые ошибки в type hints от PHP 7 до 8.4
5 версий PHPот 7.0 до 8.4
1 правилотип должен сужать, а не расширять

С выходом PHP 7 язык получил скалярные типы параметров и возвращаемых значений, а за следующие версии, до текущих 8.3 и 8.4, система типов разрослась: nullable-типы, union-типы, never, intersection-типы, readonly-свойства с типами. Чем богаче инструмент, тем легче использовать его не по назначению. Разберём пять распространённых ошибок типизации, которые встречаются даже в зрелых проектах.

Общая закономерность у всех пяти ошибок одна: type hint перестаёт описывать реальное намерение автора и начинает описывать то, что проще было написать в моменте. Статический анализатор при этом формально доволен, тип указан, предупреждений нет, но читатель кода всё равно вынужден открывать реализацию, чтобы понять, что на самом деле ожидается на входе и что будет на выходе.

Ошибка первая: strict_types без понимания последствий

Директива declare(strict_types=1) отключает неявное приведение типов аргументов, но не делает код автоматически более надёжным. Если включить её посреди проекта без ревью мест вызова, можно получить фатальные ошибки там, где раньше PHP тихо приводил '42' к int. Решение не «не включать strict_types», а включать его осознанно, с проверкой пограничных вызовов, желательно сразу на новых файлах, а не ретроактивно на всём легаси одним коммитом.

declare(strict_types=1);

function applyDiscount(int $percent): float
{
    return $percent / 100;
}

// До strict_types сработало бы с приведением, теперь это TypeError
applyDiscount('10');

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

Главная мысльТип-хинт объясняет функцию лучше любого комментария, но только если он описывает реальное намерение, а не просто гасит предупреждение статического анализатора.

Ошибка вторая: nullable как замена продуманному дизайну

Синтаксис ?string $value удобен, но его легко превратить в способ не принимать решение о дизайне: вместо явного разделения «значение обязательно» и «значения может не быть вовсе» параметр просто помечают nullable и разбираются с этим внутри функции через цепочку проверок. Если метод принимает пять nullable-параметров подряд, вероятно, часть из них на самом деле описывает разные сценарии вызова и должна быть разнесена на отдельные методы или объект-параметр.

СигнатураЧто она сообщает читателюПроблема
function find(?int $id, ?string $slug)Можно передать и то, и другое, и ничегоНеясно, какая комбинация валидна
function findById(int $id) / function findBySlug(string $slug)Два явных сценария поискаНет проблемы: намерение видно из сигнатуры

Ошибка третья: union-типы вместо общего интерфейса

Union-тип технически решает задачу компилятора, но не задачу читателя. Увидев сигнатуру с тремя-четырьмя типами через вертикальную черту, разработчик вынужден читать тело функции, чтобы понять, как именно она обрабатывает каждый вариант, то есть typehint в этом случае не экономит время на изучение кода, а просто переносит ту же работу чтения в другое место.

Union-типы, доступные с PHP 8.0, позволяют написать int|string $value и не задумываться об абстракции. Иногда это обоснованно, например при работе с идентификаторами, которые исторически бывают и числом, и строкой. Но когда union-тип растёт до трёх и более несвязанных типов, это обычно сигнал, что функция выполняет разные задачи под одним именем, и ей нужен не более широкий тип, а явный интерфейс или отдельные методы.

interface Identifiable
{
    public function id(): string;
}

function notify(Identifiable $subject): void
{
    // понятно, что ожидается, независимо от конкретной реализации
}
Если тип параметра приходится описывать через «или это, или то, или вот это», часто проще описать общее поведение через интерфейс, чем перечислять все варианты в сигнатуре.
Вы в начале разбораДальше: разбор компромиссов, чек-лист и ответы на частые вопросы.

Ошибка четвёртая: mixed как универсальный выход

Тип mixed, добавленный в PHP 8.0, полезен для методов, которые действительно работают с произвольными данными: сериализация, кеш, логирование. Проблема начинается, когда mixed ставят туда, где раньше typehint отсутствовал вовсе, просто чтобы PHPStan или Psalm перестали жаловаться. В этом случае тип формально есть, но информации для читателя он не добавляет ни на грамм по сравнению с полным отсутствием типа.

  • mixed подходит для универсальных обёрток над хранилищем или сериализацией
  • mixed не подходит, если на самом деле ожидается один-два конкретных типа
  • Перед тем как поставить mixed, стоит проверить, не сузится ли тип до union или интерфейса

Ошибка пятая: возврат object вместо конкретного типа

Скалярный тип object разрешает вернуть экземпляр любого класса. На практике это означает, что вызывающий код не может ничего сделать с результатом без проверки через instanceof или обращения к документации реализации. Тип object оправдан для действительно универсальных операций, например функции заморозки произвольного объекта в кеш, но не для метода, который реально возвращает один и тот же класс всегда.

Эта ошибка особенно часто встречается на границе между легаси-кодом и новым слоем: метод фабрики, написанный годы назад для одной сущности, начинает обслуживать вторую, третью, и вместо того чтобы завести отдельные фабрики или общий интерфейс, автор просто меняет тип возврата на object, чтобы компилятор не жаловался. Решение откладывается, а не исчезает, и следующему разработчику придётся разбираться, какой именно класс вернётся в конкретном сценарии вызова.

Актуализация под PHP 8.3 и 8.4

Современные версии языка дают больше инструментов для узкой типизации без union-заплаток: readonly-свойства с явным типом, типизированные константы класса в 8.3, enum вместо «магических строк статуса». Вместо string $status с комментарием «new, paid или cancelled» стоит использовать enum, который делает недопустимые значения невозможными на уровне языка, а не на уровне договорённости в команде.

PHP 8.4 добавил свойства с хуками доступа, что снимает часть причин, по которым разработчики раньше оставляли публичные типизированные поля вместо геттеров и сеттеров. Это не связано напрямую с ошибками типизации, но заметно упрощает написание классов, где нужен и тип, и контроль над чтением или записью значения в одном месте, без лишнего количества методов.

  1. Перед typehint спросить: какой самый узкий тип описывает реальное использование этого параметра сегодня
  2. Если напрашивается union из трёх и более типов, проверить, не разные ли это задачи под одним именем
  3. Заменять строковые «статусы» и «режимы» на enum, где это уместно
  4. Включать strict_types на новых файлах и постепенно расширять на легаси, а не одним большим коммитом

Типизация в PHP выросла из экспериментальной функции в полноценную систему, которая по возможностям приближается к языкам со статической типизацией. Польза от неё прямо пропорциональна тому, насколько тип описывает реальное намерение кода, а не просто формально проходит проверку анализатора.

Полезная практика для команды: при код-ревью спрашивать не «указан ли тип», а «что этот тип сообщает читателю о допустимых значениях». Если ответ сводится к «почти всё что угодно», стоит потратить несколько минут и сузить тип до интерфейса, enum или конкретного класса, пока код всё равно открыт для правки.

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

Частые вопросы

Стоит ли включать strict_types в старом проекте одним коммитом?

Лучше не сразу на весь проект. Безопаснее включать на новых файлах и постепенно переносить легаси, проверяя места вызова, где раньше PHP тихо приводил типы.

Когда union-тип оправдан, а когда это признак плохого дизайна?

Union из двух родственных типов, например int|string для идентификатора, обычно оправдан. Union из трёх и более несвязанных типов чаще означает, что функция решает разные задачи и её стоит разделить.

Можно ли заменить mixed на более узкий тип задним числом?

Да, и это стоит делать регулярно. mixed полезен для действительно универсального кода, но если на практике функция всегда получает один-два конкретных типа, их стоит прописать явно.

Код, который не стыдно передать

Начните с принципов, а дальше двигайтесь по рубрикам. Каждый разбор заканчивается чек-листом.

Случайный разбор