The previous post in this series added two checking layers around the types you already have: a static checker for the code you control and a runtime checker for the payloads that arrive from outside. Both layers still accept whatever the types allow, and a domain built on raw types can (and will) brake in multiple ways. This post moves the problem one level earlier, into the type definitions themselves.
The fix is to design the domain so that invalid states are not representable in the first place. Three patterns implement that idea in Python: self-validating value objects, advanced enums (StrEnum, Flag), and tagged unions verified with match/case and compile-time exhaustiveness. These three patterns are not independent tricks. They are three implementations of one idea, which Yaron Minsky named “make illegal states unrepresentable” and Alexis King crystallized as “parse, don’t validate”: the boundary parses untrusted input into a typed value, and the rest of the program can rely on the type.
Primitive obsession and invalid domain states
Consider a checkout function built on primitives:
def process_payment(amount: float, email: str, payment_method: str) -> None: print(f"Processing {amount} for {email} via {payment_method}")Passing -100.0, "garbage", and "strype" all succeed. Execution continues until a crash occurs inside the adapter, and the entry-point that allowed this input shows nowhere in the error trace. The same class of bug shows up with booleans:
@dataclassclass User: is_paid: bool is_cancelled: boolThere are three legal states here: free and active (not is_paid, not is_cancelled), paid and active, paid and cancelled. Then we have an illegal state that can be possible too: cancelled without ever paying. The code could reach a state that should not exist.
Instead, the user status should be an enum with one member per state:
from enum import Enum, auto
class UserStatus(Enum): FREE = auto() PAID = auto() CANCELLED = auto()
@dataclassclass User: status: UserStatusThere is no way to construct or mutate a User instance with an invalid state now.
Mutating between states then goes through functions that return a new User instead of assigning to user.status.
Self-validating value objects
A value object wraps a primitive and enforces its invariants at construction. In Python, we can use an immutable dataclass: __post_init__ runs right after __init__, which is where the domain check goes:
from dataclasses import dataclassimport re
@dataclass(frozen=True, slots=True)class EmailAddress: value: str
def __post_init__(self) -> None: pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" if not re.match(pattern, self.value): raise ValueError(f"Invalid email format: {self.value}")
@dataclass(frozen=True, slots=True)class Price: amount: float
def __post_init__(self) -> None: if self.amount <= 0.0: raise ValueError("Price must be strictly positive")After the boundary, EmailAddress("not-an-email") cannot exist, so the rest of the program never checks. This is the “parse, don’t validate” shape: the constructor parses, and downstream code relies on the type.
Advanced enums
An enum is commonly used to represent a closed set of categories that can be (de)serialized using formats like json or yaml. StrEnum makes the member the string itself, so status == "pending" works and JSON serialization needs no .value calls:
from enum import StrEnum, auto
class PaymentProvider(StrEnum): STRIPE = auto() PAYPAL = auto() CRYPTO = auto()auto() on a StrEnum assigns the lowercase member name, so PaymentProvider.STRIPE serializes as "stripe". That one-canonical-name-per-member mapping kills the case-mismatch problem from the primitive example: the type rejects "Strype" at the boundary.
Another use case is representing states that combine instead of excluding each other. A user does not have either read or write permission; they have a set of permissions, so combinations like “read and write but not delete” should also be representable. Flag models exactly that:
from enum import Flag, auto
class UserPermissions(Flag): READ = auto() WRITE = auto() DELETE = auto() ADMIN = READ | WRITE | DELETEIntFlag works identically but its members are also integers, which matters when the value crosses an interface that expects a numeric bitmask (a C extension, a database column, a wire format).
Literal is the type-checker-only option: it names a closed set of string values, and no runtime object exists, commonly used when the value crosses a serialization boundary untouched:
type PaymentMethod = Literal["card", "paypal", "transfer"]Plain Enum sits at the other end: the members are opaque constants with identity and methods, and comparing one against a raw string fails, which is what you want when the value should never leak out of the domain:
from enum import Enum
class OrderState(Enum): DRAFT = "draft" SUBMITTED = "submitted"Branding a primitive by wrapping it in a dedicated type (the same shape as these value objects) is the subject of the next post in this series.
Tagged unions and structural pattern matching
An Order that is placed has a placed_at timestamp; shipped has a tracking_number and a carrier; delivered has a signature. One enum member cannot carry three different shapes. When statuses carry different fields, an enum cannot hold them. That’s exactly where tagged unions come into play.
A tagged union can be seen as a combination of different classes under a single type. Imagine we have the following independent classes:
from dataclasses import dataclassfrom typing import Literal
@dataclass(frozen=True, slots=True)class StripePayment: charge_id: str type: Literal["stripe"] = "stripe"
@dataclass(frozen=True, slots=True)class PaypalPayment: billing_agreement_id: str type: Literal["paypal"] = "paypal"
@dataclass(frozen=True, slots=True)class CryptoPayment: wallet_address: str tx_hash: str type: Literal["crypto"] = "crypto"Now we can define the union alias PaymentEvent, and the handler that consumes it:
from typing import assert_never
type PaymentEvent = StripePayment | PaypalPayment | CryptoPayment
def handle_payment(event: PaymentEvent) -> None: match event: case StripePayment(charge_id=cid): print(f"Processing Stripe charge: {cid}") case PaypalPayment(billing_agreement_id=baid): print(f"Processing PayPal agreement: {baid}") case CryptoPayment(wallet_address=wallet, tx_hash=hash_val): print(f"Verifying Crypto TX {hash_val} on wallet {wallet}") case _ as unreachable: assert_never(unreachable)The match statement destructures the dataclass, allowing you to use the specific fields of each variant.
The case _ as unreachable arm passes whatever falls through to assert_never. That function’s parameter is typed Never, so the call only type-checks when the argument can never exist there (that is, when every union member is already matched above). Add a fourth payment type to the union without adding a case, and the wildcard arm becomes reachable: the static checker sees a real value flowing into a Never parameter and flags handle_payment.
This stdlib-only pattern works, but it’s not comfortable to use. The class name, the Literal value, and the match case are three copies of the same fact, and nothing forces them to change together (rename a class or edit a tag value, and the checker is your only safety net). You need to take care of the serde logic too.
msgspec and Pydantic v2 solve both issues. They generate the tag from the class (or let you name it), decode the wire format into the correct variant in one call, and keep tag, class, and decoder in a single definition. They differ a bit in the syntax, but offer similar functionalities.
Here’s an example of how to declare tagged unions with msgspec:
from msgspec import Struct
class StripePayment(Struct, tag=True): charge_id: str
class PaypalPayment(Struct, tag=True): billing_agreement_id: str
type PaymentEvent = StripePayment | PaypalPaymentEasy right? Compare this with the manual version: no type: Literal["stripe"] field to keep in sync with the class name, and no hand-written decode step. One msgspec.json.decode(payload, type=PaymentEvent) call reads the wire format, inspects the tag, and returns the right dataclass instance, ready for match/case dispatch. Adding a third variant means adding one class, and the decoder picks it up automatically.
Takeaways
- Eradicate primitive obsession by wrapping raw inputs in self-validating value objects at the boundary.
- Different enums for different use cases:
StrEnumfor categories that cross a JSON boundary,Literalfor type-checker-only string sets with no runtime object, plainEnumfor opaque domain constants, andFlag/IntFlagfor combinable states like permissions. PickIntFlagoverFlagwhen hashing or integer interop matters. - Prefer an enum member when every state holds the same fields and only the label differs; reach for a union of distinct dataclasses when states carry different fields.
- Enforce exhaustive pattern matching on tagged unions with
assert_never, and reach formsgspec.Struct(tag=True)or Pydantic v2’sField(discriminator=...)instead of a manualLiteraldiscriminator.
The next post applies the same parse-don’t-validate idea to a failure mode the type system cannot see at all: exceptions.