Typorithm · medium

Discriminated unions make variant selection explicit

pythonpydanticindependent-practice
Saved on this device
Full lesson · 4 learning steps Open any exercise for focused practice
1

Step 1 of 4 · intro card

Discriminated unions make variant selection explicit

0%

Concept and contract

Use a declared tag to choose a model shape and retain useful failures for unsupported variants.

An event boundary may accept several legitimate shapes. Trying every model in an ordinary union can produce a large collection of unrelated errors, and the choice of a successful branch may be less obvious than the API designer intended. A discriminated union names the field that selects the variant.

Our synthetic notification contract supports email and push variants. Each declares a Literal kind, and the shared event type uses kind as its discriminator. A recipient or device is simply an opaque local teaching value; no message is sent and these fields are not advertised as verified addresses or device credentials.

Mechanism and worked example

TypeAdapter validates the annotated union without adding a meaningless wrapper model.

TypeAdapter validates the annotated union without adding a meaningless wrapper model. The kind value selects Email or Push, then the selected model checks its own fields. The shared base forbids extras so the accepted payload is not silently widened with fields belonging to a different command.

Observe a valid email, an unsupported kind and a missing kind. The last two failures identify an invalid or absent union tag. The selected email variant can still fail a missing recipient check. Selecting a model and validating the contents are separate stages that should both appear in an API test suite.

Complete worked example

python

from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError

class EventBase(BaseModel):
    model_config = ConfigDict(extra="forbid")

class Email(EventBase):
    kind: Literal["email"]
    recipient: str = Field(min_length=1)

class Push(EventBase):
    kind: Literal["push"]
    device: str = Field(min_length=1)

Event = Annotated[Email | Push, Field(discriminator="kind")]
adapter = TypeAdapter(Event)

def inspect_example(payload):
    try:
        return {"value": adapter.validate_python(payload).model_dump()}
    except ValidationError as error:
        return {"error": sorted({e["type"] for e in error.errors()})}
Reasoning
TypeAdapter validates the annotated union without adding a meaningless wrapper model. The kind value selects Email or Push, then the selected model checks its own fields. The shared base forbids extras so the accepted payload is not silently widened with fields belonging to a different command. Observe a valid email, an unsupported kind and a missing kind. The last two failures identify an invalid or absent union tag. The selected email variant can still fail a missing recipient check. Selecting a model and validating the contents are separate stages that should both appear in an API test suite.

Focused decision: use the complete example for context

python

from typing import Annotated
from pydantic import Field, TypeAdapter

Event = Annotated[Email | Push, Field(discriminator="kind")]
adapter = TypeAdapter(Event)

def parse_event(payload):
    return adapter.validate_python(payload)
Focused decision
Reconstruct this focused decision after the warm-up. The complete imports, model or application, and test client remain in the worked example. Typing records practice; only the downloadable computer lab executes Python.

Counterexamples and common mistakes

A plain union may still reject an invalid payload, but it can report branch-specific literal and missing-field errors instead of the declared tag failure.

A plain union may still reject an invalid payload, but it can report branch-specific literal and missing-field errors instead of the declared tag failure. That changes the public error contract and makes the variant boundary less explicit. The controlled counterexample removes the discriminator while retaining the same model alternatives.

Do not use a broad str field for a discriminator that is supposed to enumerate known variants. Also avoid interpreting a selected kind as permission to perform that action. A valid push command still needs the correct principal, target ownership and resource budget in the service. Shape selection does not grant authority.

Controlled mistake: observe the actual wrong behavior

python

from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError

class EventBase(BaseModel):
    model_config = ConfigDict(extra="forbid")

class Email(EventBase):
    kind: Literal["email"]
    recipient: str = Field(min_length=1)

class Push(EventBase):
    kind: Literal["push"]
    device: str = Field(min_length=1)

Event = Email | Push
adapter = TypeAdapter(Event)

def inspect_example(payload):
    try:
        return {"value": adapter.validate_python(payload).model_dump()}
    except ValidationError as error:
        return {"error": sorted({e["type"] for e in error.errors()})}
Reasoning
A plain union may still reject an invalid payload, but it can report branch-specific literal and missing-field errors instead of the declared tag failure. That changes the public error contract and makes the variant boundary less explicit. The controlled counterexample removes the discriminator while retaining the same model alternatives. Do not use a broad str field for a discriminator that is supposed to enumerate known variants. Also avoid interpreting a selected kind as permission to perform that action. A valid push command still needs the correct principal, target ownership and resource budget in the service. Shape selection does not grant authority.

Independent practice: one bounded change

Add a chat variant with kind="chat" and a nonempty room string.

Add a chat variant with kind="chat" and a nonempty room string. Retain the shared extra-field policy and include the new model in the annotated union. The task introduces one small shape rather than changing the meaning of either existing variant.

Test a chat payload, an email payload and an unsupported kind. Then check an empty chat room so the new variant has a real content boundary. Do not replace the union with an arbitrary dictionary or accept every kind just to make the new example pass.

Step 1: Add a chat variant with a nonempty room and preserve discrimination and extra-field rejection. Step 2: Retain the working imports and application structure. Change the stated policy, run the supplied practice cases, then explain the newly accepted or rejected boundary.

Practice starter: solve before reading the solution

python

from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError

class EventBase(BaseModel):
    model_config = ConfigDict(extra="forbid")

class Email(EventBase):
    kind: Literal["email"]
    recipient: str = Field(min_length=1)

class Push(EventBase):
    kind: Literal["push"]
    device: str = Field(min_length=1)

Event = Annotated[Email | Push, Field(discriminator="kind")]
adapter = TypeAdapter(Event)

def inspect_example(payload):
    try:
        return {"value": adapter.validate_python(payload).model_dump()}
    except ValidationError as error:
        return {"error": sorted({e["type"] for e in error.errors()})}

# Independent task: Add a chat variant with a nonempty room and preserve discrimination and extra-field rejection.
Reasoning
Add a chat variant with a nonempty room and preserve discrimination and extra-field rejection.
  • Hint 1: Add Chat with the same EventBase and a Literal kind.
  • Hint 2: Add Chat to the annotated union; leave Field(discriminator="kind") intact.

Solution, tests and trade-offs

The solution adds Chat as another subclass of the shared base and includes it in the union.

The solution adds Chat as another subclass of the shared base and includes it in the union. The existing discriminator chooses it using the declared Literal value, and normal field validation rejects an empty room. The practice fixture verifies both the added path and an unchanged supported path.

An expanding event family also needs compatibility and consumer handling. A client that validates only older variants may reject a newly introduced kind, so adding a server-side branch is not automatically a harmless ecosystem change. Document the supported version and decide whether unknown variants should be rejected, stored separately or explicitly negotiated at another boundary.

Practice solution and acceptance checks

python

from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError

class EventBase(BaseModel):
    model_config = ConfigDict(extra="forbid")

class Email(EventBase):
    kind: Literal["email"]
    recipient: str = Field(min_length=1)

class Push(EventBase):
    kind: Literal["push"]
    device: str = Field(min_length=1)

class Chat(EventBase):
    kind: Literal["chat"]
    room: str = Field(min_length=1)

Event = Annotated[Email | Push | Chat, Field(discriminator="kind")]
adapter = TypeAdapter(Event)

def inspect_example(payload):
    try:
        return {"value": adapter.validate_python(payload).model_dump()}
    except ValidationError as error:
        return {"error": sorted({e["type"] for e in error.errors()})}
Reasoning
The solution adds Chat as another subclass of the shared base and includes it in the union. The existing discriminator chooses it using the declared Literal value, and normal field validation rejects an empty room. The practice fixture verifies both the added path and an unchanged supported path. An expanding event family also needs compatibility and consumer handling. A client that validates only older variants may reject a newly introduced kind, so adding a server-side branch is not automatically a harmless ecosystem change. Document the supported version and decide whether unknown variants should be rejected, stored separately or explicitly negotiated at another boundary.

Interview reasoning and next step

Explain the two-stage process in an interview: select a declared variant, then validate that variant.

Explain the two-stage process in an interview: select a declared variant, then validate that variant. Give a case with an unknown tag and a case with a known tag but a missing required field. Those failures should not be collapsed into the same vague statement.

The next topic validates batches without introducing a wrapper object. Keep variant identity, collection bounds and duplicate business identities distinct. A request can have a valid event shape and still contain duplicated work, exceed a batch budget or ask the caller to access an object they do not own.

Optional stretch — after the core task works: Add one independently reasoned boundary case and explain its expected value before running it.

Computer practice: https://applydjinn.com/typorithm/python-api-lab-20261105.zip. Use an isolated Python environment and the pinned dependencies. Run verify.py --track pydantic, then edit the working exercise baseline and run --practice pyd-unions. The reference suite rejects meaningful counterexamples and checks values, types and unchanged inputs. All data is owned and all HTTP clients are in-process. No production database, feed, account or paid service is used.

Use a declared tag to choose a model shape and retain useful failures for unsupported variants.