PydanticMiddleTechnical

Как валидировать generic-модели и сложные union-типы в Pydantic v2?

Generic-модели объявляются через Generic[T] + BaseModel, Union-типы обрабатываются с помощью Annotated и discriminator. Pydantic v2 поддерживает model_validate для всех этих конструкций.

Generic-модели в Pydantic v2

Для создания параметризованной модели нужно унаследоваться одновременно от BaseModel и Generic[T]. При инстанциировании Pydantic строит специализированный validator plan для конкретного типа параметра.

from pydantic import BaseModel
from typing import Generic, TypeVar, List, Optional

T = TypeVar("T")

class Page(BaseModel, Generic[T]):
    items: List[T]
    total: int
    page: int
    size: int

class UserDTO(BaseModel):
    id: int
    name: str

# Специализация: Page[UserDTO]
raw = {
    "items": [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}],
    "total": 2,
    "page": 1,
    "size": 10,
}
page = Page[UserDTO].model_validate(raw)
print(page.items[0].name)  # Alice
print(type(page.items[0])) # <class 'UserDTO'>

# Множественные TypeVar
K = TypeVar("K")
V = TypeVar("V")

class KVStore(BaseModel, Generic[K, V]):
    key: K
    value: V
    metadata: Optional[dict] = None

store = KVStore[str, int].model_validate({"key": "count", "value": "42"})
print(store.value)  # 42 (int)

Вложенные Generic

from pydantic import BaseModel
from typing import Generic, TypeVar, Optional

T = TypeVar("T")

class ApiResponse(BaseModel, Generic[T]):
    data: Optional[T] = None
    error: Optional[str] = None
    status_code: int

class Product(BaseModel):
    sku: str
    price: float

response = ApiResponse[Product].model_validate({
    "data": {"sku": "ABC-001", "price": "9.99"},
    "status_code": 200,
})
print(response.data.price)  # 9.99 (float)

Union-типы: базовый подход

Pydantic v2 поддерживает Union[A, B] и A | B (Python 3.10+). По умолчанию используется left-to-right matching: первый подходящий тип побеждает.

from pydantic import BaseModel
from typing import Union

class Cat(BaseModel):
    type: str = "cat"
    meow_volume: int

class Dog(BaseModel):
    type: str = "dog"
    bark_pitch: str

# Без дискриминатора — медленно и нестабильно на похожих схемах
class PetContainer(BaseModel):
    pet: Union[Cat, Dog]

container = PetContainer.model_validate({"pet": {"type": "dog", "bark_pitch": "high"}})
# Может выбрать Cat, если поля совпадают — непредсказуемо!

Discriminated Union с Annotated

Для надёжной маршрутизации используйте Annotated + Field(discriminator=...). Pydantic смотрит на значение поля-дискриминатора и сразу выбирает нужный валидатор — без перебора.

from pydantic import BaseModel, Field
from typing import Union, Literal, Annotated

class Cat(BaseModel):
    type: Literal["cat"]
    meow_volume: int

class Dog(BaseModel):
    type: Literal["dog"]
    bark_pitch: str

class Parrot(BaseModel):
    type: Literal["parrot"]
    vocabulary_size: int

Animal = Annotated[
    Union[Cat, Dog, Parrot],
    Field(discriminator="type"),
]

class Zoo(BaseModel):
    animals: list[Animal]

zoo = Zoo.model_validate({
    "animals": [
        {"type": "cat", "meow_volume": 8},
        {"type": "dog", "bark_pitch": "low"},
        {"type": "parrot", "vocabulary_size": 200},
    ]
})
print(type(zoo.animals[0]))  # <class 'Cat'>
print(type(zoo.animals[1]))  # <class 'Dog'>

TypeAdapter для валидации вне модели

from pydantic import TypeAdapter
from typing import Union, Literal, Annotated
from pydantic import Field

class CircleShape(BaseModel):
    kind: Literal["circle"]
    radius: float

class RectShape(BaseModel):
    kind: Literal["rect"]
    width: float
    height: float

Shape = Annotated[Union[CircleShape, RectShape], Field(discriminator="kind")]
ta = TypeAdapter(Shape)

shape = ta.validate_python({"kind": "circle", "radius": "5.0"})
print(shape.radius)  # 5.0

Подводные камни

  • Generic без специализацииPage.model_validate(raw) (без Page[UserDTO]) оставит поле items как list[Any]; специализируйте явно.
  • Порядок в Union без дискриминатораUnion[int, str] всегда выберет int для "42" через coercion; поменяйте порядок или используйте strict mode.
  • Literal с NoneUnion[Cat, None] лучше записывать как Optional[Cat]; смешивание стилей путает IDE и генераторы схем.
  • Дискриминатор должен быть Literal — поле-дискриминатор обязано иметь тип Literal["value"] в каждом варианте Union; если оно просто str, Pydantic выбросит PydanticUserError.
  • Рекурсивные Generic — модели, ссылающиеся на себя через Generic, требуют model_rebuild() после определения класса.
  • JSON Schema для GenericPage[UserDTO].model_json_schema() корректно работает только на специализированном классе; неспециализированный вернёт T как {}.

Common mistakes

  • Отвечать определением без production-сценария.
  • Не называть runtime boundary, security boundary или failure mode.
  • Игнорировать версию API, observability и тестовую проверку.

What the interviewer is testing

  • Объясняет механизм своими словами и без выдуманных API.
  • Называет реальные риски, диагностику и критерий корректности.
  • Связывает ответ с текущей документацией и миграционными ограничениями.

Sources

Related topics