Опирается на правила: R-VLD-CC-1R-VLD-CC-5 и R-VLD-CC-X1R-VLD-CC-X3 из Validation Style Guide → раздел 3. Custom constraints.

Важно знать

  • Custom constraint в Python — это Annotated[T, AfterValidator(fn)] или reusable @field_validator на базовом типе. Не inline-лямбда в DTO.
  • Пишем custom, только когда формат встречается на 3+ полях или несёт доменное имя (RussianPhone, VatNumber, BicCode).
  • Функция-валидатор вызывается только для не-None значений. Для optional-поля: RussianPhone | None = None. Null-логику в валидатор не добавляем — composability сломается.
  • Валидатор — stateless чистая функция от значения. Без глобального состояния, без singleton-зависимостей с mutable state.
  • Расположение — backend/validation/types.py. Не в файле DTO, не inline определение.
  • Имя по доменному термину: RussianPhone, VatNumber. Не ValidPhone, CheckVat, IsPhone.

Custom constraint — это способ выразить доменное правило на уровне типа. Когда формат «телефон +7XXXXXXXXXX» появляется на пятом поле в трёх разных DTO — не надо копировать regex: достаточно Annotated[str, AfterValidator(_russian_phone)] под именем RussianPhone. Тот же принцип, что Value Object: часто встречающееся правило превращается в именованную абстракцию. Раскрытие раздела 3 гайда.

Когда вводим

Триггер — формат встречается часто или имеет доменное имя:

  • Один и тот же Field(pattern=...) появляется в 3+ полях.
  • Формат имеет общепринятое имя: российский телефон, ИНН, BIC, номер карты Луна.
  • Валидация сложнее regex: контрольная сумма ИНН, алгоритм Луна.

Когда не нужен:

  • Формат уникальный для одного поля (Field(pattern=r"^CTR-\d{4}-\d{8}$") остаётся на месте).
  • Стандартный тип Pydantic покрывает (EmailStr вместо ValidEmail).
  • Правило в одну строку и не несёт доменного смысла.

Структура: Annotated-тип с AfterValidator

R-VLD-CC-1: переиспользуемый тип — Annotated alias в общем модуле.

# backend/validation/types.py
import re
from typing import Annotated
from pydantic import AfterValidator

_RU_PHONE = re.compile(r"^\+7\d{10}$")

def _russian_phone(v: str) -> str:
    if not _RU_PHONE.match(v):
        raise ValueError("Номер должен быть в формате +7XXXXXXXXXX")
    return v

RussianPhone = Annotated[str, AfterValidator(_russian_phone)]

Использование тривиально:

# backend/customer/api/request.py
from backend.validation.types import RussianPhone
from pydantic import BaseModel, EmailStr

class CreateCustomerRequest(BaseModel):
    first_name: str
    phone: RussianPhone
    alt_phone: RussianPhone | None = None
    email: EmailStr

RussianPhone — это str с дополнительным AfterValidator. Pydantic сначала проверит, что значение — строка, потом вызовет _russian_phone. alt_phone: RussianPhone | None = None — optional, при None валидатор не вызывается.

Более сложный пример: контрольная сумма ИНН

# backend/validation/types.py
def _vat_number(v: str) -> str:
    if not v.isdigit():
        raise ValueError("ИНН должен состоять из цифр")
    if len(v) not in (10, 12):
        raise ValueError("ИНН должен содержать 10 или 12 цифр")
    if not _check_inn_checksum(v):
        raise ValueError("Некорректный ИНН: неверная контрольная сумма")
    return v

def _check_inn_checksum(inn: str) -> bool:
    weights_10 = [2, 4, 10, 3, 5, 9, 4, 6, 8]
    weights_12 = [7, 2, 4, 10, 3, 5, 9, 4, 6, 8]
    if len(inn) == 10:
        return int(inn[9]) == sum(w * int(d) for w, d in zip(weights_10, inn)) % 11 % 10
    n11 = sum(w * int(d) for w, d in zip(weights_12, inn)) % 11 % 10
    n12 = sum(w * int(d) for w, d in zip([3] + weights_12, inn)) % 11 % 10
    return int(inn[10]) == n11 and int(inn[11]) == n12

VatNumber = Annotated[str, AfterValidator(_vat_number)]
class CreateOrganizationRequest(BaseModel):
    name: str
    inn: VatNumber
    kpp: str | None = None

Валидатор на не-None значении

R-VLD-CC-4: AfterValidator вызывается Pydantic только после успешного парсинга типа. Для RussianPhone (Annotated[str, ...]) — только если значение является строкой, то есть не None. Логику if v is None: return v в валидатор добавлять не нужно — это усложняет код и вводит в заблуждение.

Для optional-поля весь null-путь обрабатывается через тип:

phone: RussianPhone | None = None

Pydantic не вызовет _russian_phone при phone=None.

Валидатор — stateless

R-VLD-CC-5: функция _russian_phone — pure function от значения. Никакого глобального mutable state, никаких HTTP-вызовов внутри.

# ХОРОШО
_RU_PHONE = re.compile(r"^\+7\d{10}$")

def _russian_phone(v: str) -> str:
    if not _RU_PHONE.match(v):
        raise ValueError("Номер должен быть в формате +7XXXXXXXXXX")
    return v

# ПЛОХО — state и I/O в валидаторе
_blacklist_cache = []

def _russian_phone(v: str) -> str:
    if v in _blacklist_cache:
        raise ValueError("Номер заблокирован")
    return v

Если правило требует обращения к внешней системе (проверка ИНН в реестре ФНС) — это бизнес-проверка, не валидация формата. Место — в Handler, не в Pydantic. См. python/where-to-validate.

Расположение и нэйминг

R-VLD-CC-2: все custom-типы — в backend/validation/types.py. Не в файле DTO, не в __init__.py пакета.

backend/
  validation/
    types.py        # RussianPhone, VatNumber, BicCode, ...
    __init__.py
  customer/
    api/
      request.py    # импортирует из validation/types

R-VLD-CC-3: имя типа — доменный существительное, без префиксов.

# ХОРОШО
RussianPhone = Annotated[str, AfterValidator(_russian_phone)]
VatNumber = Annotated[str, AfterValidator(_vat_number)]
BicCode = Annotated[str, AfterValidator(_bic_code)]

# ПЛОХО
ValidPhone = ...      # что значит «valid»?
CheckVat = ...        # глагол — это команда
IsPhoneValid = ...    # предикат, не тип

Inline-валидатор в DTO — нет

R-VLD-CC-X2: соблазн определить AfterValidator прямо в DTO-файле.

# ПЛОХО — логика в файле DTO
class CreateCustomerRequest(BaseModel):
    phone: Annotated[str, AfterValidator(
        lambda v: v if re.match(r"^\+7\d{10}$", v) else (_ for _ in ()).throw(ValueError("..."))
    )]

Что не так:

  • Не переиспользуется. CreateEmployeeRequest с тем же форматом — копипаст.
  • grep RussianPhone не найдёт — нет имени.
  • Нечитаемо. Lambda с хаком вместо именованной функции.

Что запрещено

АнтипаттернПравилоЧто взамен
if v is None: return v внутри AfterValidatorR-VLD-CC-4Optional — через тип T \| None; валидатор работает только на не-None
Inline AfterValidator(lambda ...) в файле DTOR-VLD-CC-X2Annotated-тип в backend/validation/types.py
@model_validator с невыносимой логикой вместо типаR-VLD-CC-X3Переиспользуемый Annotated-тип
HTTP-запрос или mutable state в функции-валидатореR-VLD-CC-5Pure-функция; бизнес-проверки — в Handler
Имя с префиксом Valid/Check/IsR-VLD-CC-3Имя по домену: RussianPhone, VatNumber

Куда дальше

  • Validation → раздел 3. Custom constraints — нормативные формулировки R-VLD-CC-*.
  • python/standard-constraints — что есть из коробки Pydantic, до custom.
  • python/cross-field-validation — @model_validator для правил между полями.
  • python/messages-and-i18n — тексты ValueError на русском.
  • python/openapi-generated-dto — custom-тип в code-first контракте.