Skip to content

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

Before — flagged An after validator encodes a field dependency that its type cannot express
app/models.py
from typing import Literal, Self
from 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 self
After — preferred Only the email variant requires an address; pickup preserves its optional address
app/models.py
from typing import Annotated, Literal
from 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")]
Before — flagged Data variants lack a common explicit tag
app/access.py
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 | GlobalAccess
After — preferred Explicit tags preserve positional constructor calls with init=False
app/access.py
from dataclasses import dataclass, field
from 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