В первой части мы разобрались с представлением данных. Мы сделали невалидные состояния невозможными для выражения в рамках модели. В этой части мы разберемся с тем, как можно выражать поведение таких моделей, и где проходит граница между тем, что ловит чекер, и тем, что придется оставить рантайму.
Где живёт поведение
Когда мы говорим о некотором семействе типов, вокруг которых должно строиться поведение, то здесь есть как минимум два подхода:
Оставить модель анемичной записью, а поведение вынести в функцию со сверткой типов:
def _render_predicate(self, p: Predicate, params) -> str: column = self._column(p.field) match p: case NumericRangePredicate() | DateRangePredicate(): ... # BETWEEN low AND high case SetPredicate(): ... # IN (...) case NumericPredicate() | DatePredicate() | CategoryPredicate(): ... # column <op> value case _: typing.assert_never(p)
Удобно, когда операций много, каждая новая операция это новая функция в одном месте, но приходится платить тем, что, при добавлении нового типа, придётся обойти все такие функции.
Спрятать поведение внутрь, по методу на каждый вариант:
class FilterDefinition(BaseModel, abc.ABC): field: Field field_type: FieldType # ... @abc.abstractmethod def to_predicate(self, value: FilterValue | None = None) -> Predicate | None: ... class NumericFieldFilter(FilterDefinition): field_type: typing.Literal[FieldType.NUMERIC] = FieldType.NUMERIC operator: EqualityOperator | OrderOperator | RangeOperator # ... def to_predicate(self, value=None) -> NumericPredicate | NumericRangePredicate | None: ...
Теперь дёшево добавлять новые типы, но дорого добавлять операции, новый метод нужно будет дописать в каждый класс.

Так можно ли добавлять и новые варианты данных, и новые операции, не переписывая старое и не теряя проверок типов? Давайте посмотрим на таблицу выше. Функциональный стиль режет таблицу по столбцам: операция видит сразу все строки. ООП режет по строкам: класс реализует сразу все столбцы. Что дёшево добавлять по одной оси, дорого по другой, без трейд-оффа здесь обойтись не выйдет. Фил Вадлер описал это как expression problem.
Выбирают обычно по тому, что будет меняться чаще. Если у сущности множатся операции, а набор вариантов стабилен, то берём сумму-тип со свёртками снаружи. Если же варианты множатся, а операция по сути одна, то берём классы с методом. Видеть оба подхода рядом в одной кодовой базе тоже нормально: у предиката стабильные варианты и куча операций над ними, у модели фильтра наоборот.
Когда добавляешь вариант и ничего не забываешь
Вернёмся к свёртке из прошлого раздела:
case _: typing.assert_never(p)
assert_never — функция, у аргумента которой тип Never, тип без значений. Пока match разбирает все варианты, в ветку case _ не попадает ни один: для тайп-чекера p там сужен до Never. Если добавим новый вариант и забудем его указать где-то, то p окажется не Never, и assert_never перестанет проходить проверку типов. То есть тайп-чекер сам нам укажет на все места, где нам нужно внести изменения.
Добавим вариант
Допустим, понадобилось новое условие: «поле пустое / не пустое», IS NULL.
class NullPredicate(BaseModel): kind: typing.Literal["null"] = "null" field: Field operator: PresenceOperator # is_null, is_not_null # значения нет
Добавим его в union:
Predicate = typing.Annotated[ NumericPredicate | ... | SetPredicate | NullPredicate, Field(discriminator="kind"), ]
Выбирая поведение в функции, мы приняли трейд-офф в виде необходимости дописать новый тип в каждую операцию. Но благодаря assert_never этот обход превращается из «не забыть бы» в гарантированный. Запускаем тайп-чекер, и он немедленно укажет нам на каждую функцию, где match заканчивался на assert_never:
error: Argument 1 to "assert_never" has incompatible type "NullPredicate"; expected "Never" [arg-type]
Для сравнения, на стороне FilterDefinition тот же шаг — один новый подкласс, и ничего не ломается. Зато новая операция потребовала бы такого же обхода по всем классам, и честность гарантировалась бы @abstractmethod.
Полнота там, где чекер не дотянется
К сожалению, не всё можно поручить тайп-чекеру. Скажем, есть таблица «тип поля → класс фильтра», и она обязана покрывать все типы полей до единого. Тайп-чекер этого не проверит. Зато можно проверить на старте, при импорте модуля:
field_type_filter_mapping = { _filter_field_type(variant): variant for variant in typing.get_args(typing.get_args(FilterType)[0]) } assert set(field_type_filter_mapping) == set(FieldType), "каждому типу поля нужен фильтр"
Тут мы используем два приёма сразу. Во-первых, варианты не выписаны руками, а вытащены из самого union через typing.get_args, один источник правды, список не разъедется с определением. Вложенный get_args нужен, потому что FilterType — это Annotated[Union[…], …]. Во-вторых, assert на полноту, если добавил тип поля, но забыл фильтр, то модуль просто не импортируется, получим ошибку при старте приложения. То, что в языке с проверкой полноты доказал бы компилятор, мы утверждаем руками в момент загрузки. Это не много, но это лучше, чем ничего.
Тег это контракт, а не деталь реализации
Есть тонкость, которую легко проглядеть. Значение тега kind это не внутренняя деталь. Как только размеченный объект уехал в JSON, лёг в базу или ушёл другому сервису, строка тега стала контрактом с внешним миром. Переименовать класс NumericPredicate ничего не стоит. Переименовать его тег "numeric" это тихо сломать каждую сохранённую строку и каждого клиента, который всё ещё шлёт старое значение.
Отсюда возникает рекомендация значения тегов держат стабильными и отвязанными от имён классов. Мне нравится практика неймспейсинга, например, использовать "filter/numeric" вместо просто "numeric".
Что происходит при добавлении варианта в уже работающей системе?
Старые данные, новый код. В новом коде есть
NullPredicate, но в старом сохранённом JSON тега"null"просто нет, всё парсится как раньше. Добавление варианта обратно-совместимо само по себе.Новые данные, старый код. А это уже больно. Старый сервис получает
kind: "null", которого не знает, и дискриминированный union честно падает. Поведение правильное (лучше громко, чем молча), но требует дисциплины: катить продьюсеров после консьюмеров, держать окно совместимости или заранее учить старый код терпеть незнакомые теги.
Потолок
Соберём вместе всё, что за обе части так и осталось рантайму: start ≤ end у диапазона, «у выражения без оператора ровно один операнд», совпадение типа поля и варианта предиката, len(s) ≥ 1 у непустой строки. Это случаи зависимости значения от значения. Тип «диапазон, у которого начало не больше конца» нельзя описать, не позволив типу заглянуть внутрь значений.
Языки, которые так умеют, существуют: зависимые типы в Idris, Agda, Lean, F*, уточняющие типы в Liquid Haskell. Там Range мог бы иметь тип, гарантирующий start ≤ end ещё на компиляции, и нарушающее значение не собралось бы вовсе. Python сознательно остаётся на прагматичном уровне: статика держит структуру (какой вариант, какой тип значения, полнота разбора), зависимости значений проверяет рантайм, а pydantic делает эту проверку дешёвой и в одном месте.

Что в итоге
Граница теперь видна целиком. Тайп-чекер держит структуру и, через assert_never, полноту разбора. Стартовые assert закрывают полноту таблиц, до которых чекер не дотягивается.
«Сделать невалидные состояния невыразимыми» — это не призыв затащить в типы вообще всё. В Python всё и не затащишь. Нужно подвести черту: что сделать невыразимым (неправильную пару оператор-значение, пустую строку там, где нужна непустая, незакрытый вариант в match), а что честно оставить рантайму. Хорошо проведённая черта стоит больше, чем максимум аннотаций. А value: Any с валидатором на сто строк, с которого мы начинали, это просто отсутствие такой границы.