prefer-discriminated-model-union
Give data unions explicit Literal discriminators and express tag-dependent payloads as variants.
Why
An explicit shared tag identifies data variants and supports field-based narrowing. A nullable field remains statically nullable even when an after validator requires it for a literal tag. A union of models with distinct Literal tags carries that dependency in the type and supports safe narrowing without repeating runtime guards.
Fix
Give every data variant a common field with disjoint Literal values. For dataclasses, field(default=..., init=False) can preserve existing constructor calls; review serialized contracts. Consider a discriminated union of model variants. Make the payload non-nullable only in variants proven to reject None, and preserve every other accepted field combination, defaults, aliases and wire shape. Independent optional fields can remain optional within each variant.
Examples
from typing import Literal, Selffrom pydantic import BaseModel, model_validator
class Delivery(BaseModel): kind: Literal["email", "pickup"] address: str | None = None
@model_validator(mode="after") def validate_address(self) -> Self: if self.kind == "email" and self.address is None: raise ValueError("Email requires an address") return selffrom typing import Annotated, Literalfrom pydantic import BaseModel, Field
class EmailDelivery(BaseModel): kind: Literal["email"] address: str
class PickupDelivery(BaseModel): kind: Literal["pickup"] address: str | None = None
type Delivery = Annotated[EmailDelivery | PickupDelivery, Field(discriminator="kind")]from dataclasses import dataclass
@dataclass(frozen=True, slots=True)class TeamAccess: team_id: str
@dataclass(frozen=True, slots=True)class GlobalAccess: group: str
type Access = TeamAccess | GlobalAccessfrom dataclasses import dataclass, fieldfrom typing import Literal
@dataclass(frozen=True, slots=True)class TeamAccess: scope: Literal["team"] = field(default="team", init=False) team_id: str
@dataclass(frozen=True, slots=True)class GlobalAccess: scope: Literal["global"] = field(default="global", init=False) group: str
type Access = TeamAccess | GlobalAccess