Constructive Data Modeling in Python¶
In the world of static typing, we are living in a renaissance. The rise of languages like Rust and TypeScript has made static typing popular again. However, this renaissance has brought a rapid increase in type system complexity. Developers often find themselves playing “type Tetris”—fighting with highly complex type checkers or searching for advanced type-level machinery to enforce invariants.
But you don’t need bleeding-edge, highly complex type system features to write safe, self-documenting code. You can achieve high levels of type safety using just three simple ingredients:
- Product types (combining values together, like tuples or records)
- Sum types (representing choices, like unions or enums)
- Exhaustive case analysis (pattern matching with compiler-verified coverage)
These features are fully available in modern Python (Python 3.10+). By applying constructive data modeling—defining the “positive space” of what is valid rather than trying to restrict “negative space” after the fact—we can eliminate entire classes of bugs before our code ever runs.
This is based on https://www.youtube.com/watch?v=0BXuYlNrUmE
The Python Toolset¶
Python 3.10+ provides exactly what we need to implement constructive data modeling:
- Product Types:
@dataclassorNamedTuple - Sum Types: The union operator
|(ortyping.Union) - Exhaustive Case Analysis: Structural pattern matching (
match/case) combined with static type checkers likemypyorpyrightusingtyping.assert_never.
Principle 1: Define the Positive Space¶
When we think of types as restrictions, we start with a large set of possible values and try to rule out the invalid ones. This is difficult to enforce statically.
Instead, we should build up from nothing and define only the positive space—the set of values that are actually valid.
Example: The Non-Empty List¶
Suppose we want to guarantee that a list has at least one element. In a “types-as-restrictions” mindset, we might accept a standard list and write runtime checks to ensure len(lst) > 0. If the list is empty, we raise an exception.
In a constructive mindset, we define the positive space. A non-empty list is simply one element, followed by zero or more elements. We can model this directly as a product of a head and a tail:
from dataclasses import dataclass
from typing import Generic, TypeVar
T = TypeVar("T")
@dataclass(frozen=True)
class NonEmptyList(Generic[T]):
head: T
tail: list[T]
def to_list(self) -> list[T]:
return [self.head] + self.tail
By changing our representation, we make it structurally impossible to construct an empty list. Any function requiring a NonEmptyList can safely extract head without fear of an IndexError or a None value.
Example: The “At Least One” Constraint¶
Imagine a User class where a user must have either an email, a phone number, or both. A naive implementation might look like this:
# The "negative space" approach
@dataclass
class User:
email: str | None = None
phone: str | None = None
# Comment: At least one of email or phone must not be None!
This relies on comments and runtime validation. If we instead construct the positive space, we can explicitly represent the three valid states using a sum type (Union):
@dataclass(frozen=True)
class EmailContact:
email: str
@dataclass(frozen=True)
class PhoneContact:
phone: str
@dataclass(frozen=True)
class EmailAndPhoneContact:
email: str
phone: str
# Our Sum Type
ContactInfo = EmailContact | PhoneContact | EmailAndPhoneContact
@dataclass(frozen=True)
class User:
id: int
contact: ContactInfo
The constraint is now enforced by the structure of the data itself. There is no way to construct a User with missing contact details.
Principle 2: Decouple Representation from Interpretation¶
Sometimes, representing data constructively feels like “cheating” because it doesn’t match our traditional view of the data.
Consider a TimeRange where the start time must be less than or equal to the end time:
from datetime import datetime, timedelta
# Traditional approach (requires validation)
@dataclass
class TimeRange:
start: datetime
end: datetime # Must be >= start
If we decouple the representation from how we interpret the end time, we can model this constructively by storing the start time and a non-negative offset:
# Constructive approach
@dataclass
class TimeRange:
start: datetime
duration: timedelta # Assumed to be non-negative
If your application frequently calculates durations, this representation is highly convenient. If you frequently need the end time, you can expose it as a property:
@property
def end(self) -> datetime:
return self.start + self.duration
Freeing your mind from the idea of a “privileged, ideal representation” allows you to choose structures that make your specific invariants impossible to violate.
Principle 3: Use Types as an “Obligation Propagation Machine”¶
A primary benefit of static typing is that it keeps track of the different cases you must handle in your code. To leverage this in Python, we should strive to write total functions—functions that return a valid output for every possible input of their declared types, without raising unexpected runtime exceptions.
We can enforce this using Python’s structural pattern matching and typing.assert_never.
from typing import assert_never
def get_contact_display(contact: ContactInfo) -> str:
match contact:
case EmailContact(email):
return f"Email: {email}"
case PhoneContact(phone):
return f"Phone: {phone}"
case EmailAndPhoneContact(email, phone):
return f"Email: {email}, Phone: {phone}"
case _:
# Static type checkers (mypy/pyright) will flag an error
# if we miss a case or add a new variant to ContactInfo later.
assert_never(contact)
If we later add a SlackContact to ContactInfo, our type checker will immediately fail at assert_never(contact), pointing us directly to the function we forgot to update. The type system acts as an obligation propagation machine, ensuring we handle every case.
Principle 4: Push Obligations to the Right Place¶
When dealing with optional values (Optional[T] or T | None), we must decide where to handle the missing case.
Consider a function designed to notify a user when a system failure occurs:
# Option A: Handle the optionality inside the function
def notify_failure(user: User | None) -> None:
if user is None:
# What do we do here? Log? Raise an exception? Quietly ignore?
return
send_email(user.contact, "System failure occurred")
If we pass None to this function, we push the decision-making down to a deeply nested utility. If ignoring it is a silent failure, this can hide bugs.
Instead, we can rewrite the function to accept only a valid User:
# Option B: Push the obligation outward
def notify_failure(user: User) -> None:
send_email(user.contact, "System failure occurred")
By changing the type signature, we push the obligation of handling the missing user upstream to the caller—the place that actually knows the context of why the user might be missing (e.g., an unauthenticated endpoint vs. a critical background task).
Summary of the Approach¶
By adjusting how we model data, we can let our type checker do the heavy lifting without relying on overly complex type system features:
- Define the positive space: Construct your types so that invalid states cannot be represented.
- Decouple representation from interpretation: Don’t get stuck on a single “correct” data shape; use shapes that structurally guarantee your invariants.
- Write total functions: Use pattern matching and
assert_neverto let the type checker track your code-handling obligations. - Push obligations around: Use non-optional types to force callers to handle edge cases where they have the most context.
These techniques do not require advanced type-level programming. With simple dataclasses, unions, and pattern matching, you can make your Python code significantly more robust and easier to maintain.
Page last modified: 2026-08-31 15:31:45