Type-safe Python design: patterns a Scala developer uses to stop runtime surprises
I’ve been writing Scala for years. At some point the compiler stops feeling like an obstacle and starts feeling like a colleague - one who reads every line you write and says “no, that doesn’t make sense” before you ship it. That’s a good feeling.
Then a project came in. Real-time signal processing, embedded hardware, weeks to prototype. The kind of system that would run unattended in remote locations where you can’t push a hotfix at 4am. The team had already decided on Python. I had no say.
My first reaction was the usual one. My second was to think: okay, Python’s type system exists. Most people treat it as optional decoration. What if I didn’t?
This post is what I built over the next few weeks - a system of patterns that brought Scala-level design discipline into plain Python. No third-party libraries. Just the standard library, mypy in CI, and twenty-four patterns I now use on every Python project that matters.
Type safety is non-negotiable. Runtime surprises in production aren’t a personality quirk of dynamically typed languages - they’re a sign that the code wasn’t designed to be trusted.
Why this matters
Here’s the bug that started it.
The system consumed audio from a sensor at a certain sample rate (Hz) and also used window sizes in samples. Both were float or int. Both were passed around as parameters. At one point, deep in a feature extraction function, someone passed 44100 where the code expected a sample count, not a rate. Same number, different meaning. Python accepted it without complaint. The output was plausible enough to pass casual inspection.
The error surfaced hours later. Silently wrong output is worse than a crash - a crash tells you where to look.
In Scala, this bug is literally impossible:
| |
That’s it. The types encode the domain meaning. The compiler enforces it. The Saturday debugging session never happens.
Python can do this. Not at the language runtime level - but with mypy running in CI, you get the same safety net. The patterns below are how.
What we’ll build
Twenty-four patterns, grouped by what they prevent. Each one is:
- stdlib only (no pip install)
- annotated with which Python version it needs
- shown with the Scala original so the translation is clear
- usable independently - you don’t need to adopt all twenty-four at once
At the end there’s a version compatibility table and a standalone design example that combines the core patterns.
flowchart TD
ROOT["Production problem"] --> C1["Unit confusion\nPart 1: NewType"]
ROOT --> C2["Mutable / invalid state\nParts 2, 13: Frozen dataclass, Smart ctors"]
ROOT --> C3["Swallowed errors\nParts 3, 14: Result, Validated"]
ROOT --> C4["Inheritance coupling\nParts 4, 15: Protocol, Composition"]
ROOT --> C5["Missing match cases\nPart 5: Exhaustive match"]
ROOT --> C6["Implicit side effects\nParts 19–22: Reader, Writer, State, Monoid"]
style ROOT fill:#e1f5ff,stroke:#0066cc,color:#000
style C1 fill:#e1ffe1,stroke:#2d7a2d,color:#000
style C2 fill:#e1ffe1,stroke:#2d7a2d,color:#000
style C3 fill:#ffe1e1,stroke:#cc0000,color:#000
style C4 fill:#f0e1ff,stroke:#8800cc,color:#000
style C5 fill:#fff4e1,stroke:#cc8800,color:#000
style C6 fill:#f0e1ff,stroke:#8800cc,color:#000
Part 1 - Domain NewTypes: stop mixing units
Why. When multiple parameters share the same base type (float, int, str), a caller can pass them in the wrong order. The function runs. Something is silently wrong.
How. NewType creates a named alias that mypy treats as a distinct type at check-time, with zero runtime overhead. It’s Python’s equivalent of Scala 2’s AnyVal value classes or Scala 3’s opaque type.
| |
| |
What you get:
| |
Cross-domain arithmetic also gets flagged:
| |
NewType is available from Python 3.5.2. In Python 3.10+, NewType became a class (not a function alias) - same behaviour, slightly better error messages. No workaround needed for older versions; the typing module is the entire API.Part 2 - Frozen dataclasses as sealed ADTs
Why. Domain objects that can be mutated after construction are a liability. An Order with a negative price shouldn’t exist at all - not as a “partially initialised object” waiting for validation, not as something that passed construction and got corrupted later. Valid construction should be the only kind.
How. @dataclass(frozen=True) gives you an immutable value type. __post_init__ is the smart constructor - validation happens at build time, not scattered through the codebase.
| |
| |
What you get:
Order("SKU-1", -5.0, 10)raises immediately at construction- Fields cannot be reassigned after construction (
FrozenInstanceError) __eq__and__hash__are generated from field values - the object behaves like a value- Two
Order("SKU-1", 9.99, 5)objects compare equal and hash identically - safe forset/dictkeys
For sum types (types with exactly N variants), combine frozen=True with @final:
| |
@final tells mypy that Ok and Err cannot be subclassed. Combined with match (Part 5), this gives exhaustive dispatch - the Python analogue of Scala’s sealed trait.
@dataclass requires Python 3.7+. @final decorator requires Python 3.8+. slots=True as a dataclass argument (zero-overhead frozen objects) requires Python 3.10+. For 3.7-3.9 you can define __slots__ manually alongside the dataclass.Part 3 - The Result type: explicit error channels
Why. Python’s standard error model is exceptions. A function’s signature says nothing about what it can fail with, and callers have no obligation to handle failures at all. This leads to missing try/except, inconsistent error propagation, and bugs that surface only in production edge cases.
The Scala solution - Either[L, R] or Try[T] - makes the error part of the return type:
| |
The caller cannot ignore it. The signature tells the whole story.
How. Build a Result[T, E] type using Generic and frozen dataclasses:
| |
What you get. Functions that can fail announce it:
| |
Callers can’t ignore the error. They choose how to handle it:
| |
The flat_map chain is railroad-oriented: the first Err short-circuits everything after it. No nested try/except. No if result is None. The error path is explicit and unavoidable.
The rule I follow: pure functions return Result. Effectful functions (I/O, network, filesystem) can raise. The try_* wrapper converts the exception to an Err at the boundary.
flowchart LR
L["try_load_config()"] --> D1{"Ok or Err?"}
D1 -->|"Ok(config)"| V["try_validate_schema()"]
D1 -->|"Err(msg)"| EXIT["Err - chain stops"]
V --> D2{"Ok or Err?"}
D2 -->|"Ok(valid)"| B["try_build_pipeline()"]
D2 -->|"Err(msg)"| EXIT
B --> D3{"Ok or Err?"}
D3 -->|"Ok(pipeline)"| RUN["run(pipeline)"]
D3 -->|"Err(msg)"| EXIT
style L fill:#e1f5ff,stroke:#0066cc,color:#000
style D1 fill:#fff4e1,stroke:#cc8800,color:#000
style D2 fill:#fff4e1,stroke:#cc8800,color:#000
style D3 fill:#fff4e1,stroke:#cc8800,color:#000
style V fill:#f0e1ff,stroke:#8800cc,color:#000
style B fill:#f0e1ff,stroke:#8800cc,color:#000
style RUN fill:#e1ffe1,stroke:#2d7a2d,color:#000
style EXIT fill:#ffe1e1,stroke:#cc0000,color:#000
Part 4 - Protocol as structural type class
Why. The classical OOP solution for “this function should accept any X that has method Y” is inheritance: define a base class, subclass it. The problem is tight coupling. Your BaseExtractor, once exported, is part of your public API forever. Every library that wants to plug in must import it. A plain function or lambda never qualifies even if the signature matches perfectly.
Scala’s answer is type classes and structural types:
| |
Python’s answer is Protocol - structural subtyping:
| |
What you get. Any callable with the right signature satisfies Extractor automatically, without inheriting from it:
| |
@runtime_checkable lets you use isinstance(fn, Extractor) for defensive checks at boundaries.
Why this is the right design: the pipeline owns the Protocol definition. Implementations don’t know about the pipeline. You can add new implementations from a different module, a different team, even a third-party package - without touching the pipeline. This is the Open-Closed Principle through structural typing, not class hierarchies.
Protocol requires Python 3.8+. For 3.7, install typing_extensions - from typing_extensions import Protocol. @runtime_checkable is also in typing_extensions. The rest works unchanged.Part 5 - Exhaustive match on sealed types
Why. Adding a new variant to an enum or sealed type should force every switch/match statement on that type to be updated. In Scala, the compiler enforces this on sealed trait hierarchies - you get a warning if a match is non-exhaustive. Most Python code today uses if isinstance(x, A): ... elif isinstance(x, B): ... chains where forgetting a case is silent.
How. Python 3.10 structural pattern matching on @final frozen dataclasses:
| |
Enums work the same way:
| |
Match goes further than switch. You can destructure, add guards, and match on nested structure:
| |
- Guards (
if payload) run after the structural pattern matches - Destructuring with
body=payloadbinds the field value to a local name case _:is your explicit fallthrough - omitting it and adding# type: ignoreis a code smell
Pre-3.10 alternative. If you’re stuck on Python 3.9 or earlier, isinstance chains get you most of the way. You lose guard syntax and structured destructuring, but mypy still narrows types after each isinstance check:
| |
Part 6 - Literal types for controlled values
Why. A function that accepts method: str will accept anything - "GET", "GETT", "Bananas". There’s no way to say “only these four strings are legal” without runtime validation that could silently get missed.
Scala has literal types:
| |
How. Python’s Literal does the same:
| |
Literal composes well with Union:
| |
And it’s useful for boolean flags that should be distinct:
| |
When Literal is overkill. If the set of values changes at runtime (loaded from a database, config file, or external API), Literal won’t help - it’s a static type. Use an Enum instead. If the set is fixed at design time, Literal is the right tool.
Literal requires Python 3.8+. For 3.7, use typing_extensions.Literal. For older versions, use Enum - it provides runtime enforcement at the cost of some ergonomics.Part 7 - TypedDict for structured data contracts
Why. dict[str, Any] is the Object of Python - you can put anything in it and nothing warns you when you access a key that doesn’t exist or has the wrong type. Across API boundaries, between services, or in event-driven pipelines, these untyped dicts are where bugs hide.
Scala models this with case classes. The Python equivalent for existing dict-shaped data is TypedDict:
| |
For partial dicts (not all keys required), use total=False or mix Required/NotRequired:
| |
TypedDict is structural - any dict with the right keys and types satisfies it. You don’t need to call a constructor. This makes it the right choice for external data (JSON from an API, database rows, config files) where you receive plain dicts but want type-checked access.
TypedDict when you’re consuming dicts from outside your code (APIs, JSON, db rows). Use @dataclass(frozen=True) for your own domain objects. Once you’ve validated external data into a TypedDict, convert it to a frozen dataclass as early as possible.Part 8 - Generics, TypeVar, and variance
Why. A Stack that accepts Any is not a stack - it’s a bag of surprises. You push an int, you pop a str. No warning. Scala’s generics with variance annotations prevent this at compile time.
How. Python’s TypeVar with Generic:
| |
Variance controls whether Stack[Dog] can substitute for Stack[Animal]:
| |
TypeVar with bounds - like Scala’s T <: Animal:
| |
Python 3.12+ shorthand drops the TypeVar boilerplate:
| |
This is PEP 695 - same semantics, much less ceremony.
Part 9 - Overloaded signatures with @overload
Why. Some functions behave differently based on input type - not because they’re poorly designed, but because the domain genuinely calls for it. json.loads returns Any, but you know it returns dict when you pass str. @overload lets you encode the relationship precisely.
| |
The @overload stubs are type-checker-only - they never execute. The final implementation without @overload is what runs. Mypy sees the stubs and enforces the return type per input type.
Where this shines: any function that has a conditional return type based on input. Without @overload, you’d write -> str | bytes | dict, which forces callers to check types themselves. With it, the correct type flows through:
| |
Part 10 - TypeGuard for safe narrowing
Why. isinstance(x, str) narrows the type inside the if block. But custom validation functions don’t narrow anything - mypy has no idea that is_valid_email(s) implies s: str. TypeGuard bridges that gap.
| |
This is especially useful for custom validators and parsers at I/O boundaries:
| |
TypeGuard is in typing from Python 3.10. For 3.9 and earlier, use typing_extensions.TypeGuard - same API, backported. No other workaround needed.Part 11 - Final constants and ClassVar
Why. MAX_RETRIES = 3 at module level can be reassigned by anyone, anywhere. In Scala, val MAX_RETRIES = 3 is immutable by definition. Python’s Final gives the same guarantee at the type-checker level.
| |
ClassVar marks class-level attributes that should not appear on instances:
| |
Without ClassVar, mypy can’t distinguish between dataclass fields and class-level constants. With it, Config.DEFAULT_TIMEOUT is valid but Config("localhost", 8080).DEFAULT_TIMEOUT = 60.0 is a type error.
Part 12 - Phantom types: compile-time state machines
Why. Some operations must happen in a fixed order. You should never call .build() on a pipeline builder before .add_source(). The naive protection is a runtime ValueError - and honestly, most codebases stop there. The better protection is making the wrong order unrepresentable - the type system refuses to compile it.
This is the Builder + Phantom Types pattern from Scala. Phantom types exist only at the type-checker level; they have zero runtime overhead.
| |
Python translation uses Generic[S] where S is a Literal type acting as the phantom:
| |
What you get:
| |
The phantom type S is never stored, never allocated, never checked at runtime. It only exists so mypy can track builder state transitions. The runtime cost is zero.
flowchart LR
N["PipelineBuilder.new()\nstate = empty"] -->|add_source| S["PipelineBuilder\nstate = with_source"]
S -->|add_sink| C["PipelineBuilder\nstate = complete"]
C -->|build| R["pipeline(source → sink)"]
N -. "add_sink() ✗" .-> E1["mypy error:\nexpected with_source"]
S -. "build() ✗" .-> E2["mypy error:\nexpected complete"]
style N fill:#e1f5ff,stroke:#0066cc,color:#000
style S fill:#fff4e1,stroke:#cc8800,color:#000
style C fill:#e1ffe1,stroke:#2d7a2d,color:#000
style R fill:#e1ffe1,stroke:#2d7a2d,color:#000
style E1 fill:#ffe1e1,stroke:#cc0000,color:#000
style E2 fill:#ffe1e1,stroke:#cc0000,color:#000
Self-style binding via self: PipelineBuilder[...]. For 3.8-3.9, the same pattern works but requires from __future__ import annotations and the type of self must be declared as a string literal. The phantom approach also works in Python 3.8 using a separate TypeVar per state.Part 13 - Smart constructors: parse, don’t validate
Why. There’s a subtle but important difference between “validate” and “parse.” Validation checks whether data is valid but returns the same unproven type. Parsing produces a new type that carries proof of the validation.
With __post_init__ (Part 2), an Order that passes construction is valid - but the type is still called Order and any function that receives an Order can’t tell from the type whether it’s been checked. This is fine for small codebases. In larger ones, you want the type itself to be proof.
| |
Python translation with a private constructor pattern:
| |
Usage - the caller always handles both outcomes:
| |
The key difference from __post_init__. __post_init__ raises. The function’s type signature doesn’t tell you it can fail. parse returns Result[Email, str] - callers know at a glance that construction can fail and what the failure type is. The type signature is the contract.
Stricter enforcement with __new__. If you want to truly block direct construction:
| |
This is heavier - use it when you genuinely want to prevent accidental direct construction.
Part 14 - Accumulating errors: the Validated pattern
Why. Result[T, E] is fail-fast. The first Err short-circuits the chain. That’s exactly right for sequential logic - if step 1 fails, step 2 can’t proceed.
But form validation is different. When a user submits a registration form with an invalid email, a short password, and a missing name, you want all three errors at once - not just the first one. Fail-fast would mean three round trips to fix one form.
Scala solves this with Validated[E, A] where E has a Semigroup instance (can combine errors):
| |
Python translation - pure stdlib, no cats:
| |
When to use Valid vs Result. The rule is about dependency:
- If step B needs the output of step A, use
Result(fail-fast, sequential) - If steps A, B, C are independent checks, use
Valid(collect-all, parallel)
Typical split: Valid at I/O boundaries (form input, config parsing, API request validation). Result inside domain logic.
flowchart TD
Q{"Do the checks\ndepend on each other?"}
Q -->|"Yes - step B needs step A output"| USE_R["Use Result\nfail-fast railway"]
Q -->|"No - independent checks"| USE_V["Use Valid\ncollect all errors"]
USE_R --> EX_R["load config\n→ validate schema\n→ build pipeline"]
USE_V --> EX_V["check name\n+ check email\n+ check age\n→ all errors at once"]
style Q fill:#fff4e1,stroke:#cc8800,color:#000
style USE_R fill:#e1f5ff,stroke:#0066cc,color:#000
style USE_V fill:#f0e1ff,stroke:#8800cc,color:#000
style EX_R fill:#e1ffe1,stroke:#2d7a2d,color:#000
style EX_V fill:#e1ffe1,stroke:#2d7a2d,color:#000
Part 15 - Protocol composition: small interfaces that combine
Why. Scala’s strength is fine-grained traits that compose:
| |
Python’s Protocol composes the same way - Interface Segregation Principle enforced by the type system, not by convention.
| |
Now functions declare exactly the capability they need:
| |
open("file", "rb") satisfies Readable. io.BytesIO satisfies ReadWriteSeek. A custom network stream satisfies only Readable. None of them inherit from your Protocol. They just match the shape.
The ISP payoff. copy_data doesn’t ask for Seekable - it doesn’t need it. If tomorrow you need to copy from a network socket (not seekable), no change is required. The function already accepts any Readable. Contrast with an inheritance-based approach where you’d need to either break the hierarchy or add a no-op seek() to the socket class.
flowchart LR
R["Readable\nread()"] --> RW["ReadWrite\nread() + write()"]
W["Writable\nwrite()"] --> RW
RW --> RWS["ReadWriteSeek\nread() + write() + seek()"]
S["Seekable\nseek() + tell()"] --> RWS
RWS --> IMPL1["io.BytesIO\nsatisfies all three"]
R --> IMPL2["network socket\nReadable only"]
RW --> FN1["copy_data(src, dst)\nneeds Readable + Writable"]
RWS --> FN2["random_access_read()\nneeds all three"]
style R fill:#e1f5ff,stroke:#0066cc,color:#000
style W fill:#e1f5ff,stroke:#0066cc,color:#000
style S fill:#e1f5ff,stroke:#0066cc,color:#000
style RW fill:#f0e1ff,stroke:#8800cc,color:#000
style RWS fill:#f0e1ff,stroke:#8800cc,color:#000
style IMPL1 fill:#e1ffe1,stroke:#2d7a2d,color:#000
style IMPL2 fill:#e1ffe1,stroke:#2d7a2d,color:#000
style FN1 fill:#fff4e1,stroke:#cc8800,color:#000
style FN2 fill:#fff4e1,stroke:#cc8800,color:#000
Part 16 - Callable type aliases: function contracts as types
Why. Functions are values in Python. When you accept a callback, a transform, or a strategy, the type is Callable[[ArgTypes], ReturnType]. Writing these inline everywhere is noisy, and there’s no name to communicate the domain meaning.
Scala handles this with type aliases and function types:
| |
Python’s version:
| |
Now function signatures read as domain language:
| |
The Transformer[dict, dict] and Validator[dict] communicate intent. A future reader knows at a glance that transform is a pure data converter and validate can fail. Without the aliases, all three would be Callable[..., ...] and you’d need to read the implementation.
Combine with @overload for multiple contract flavours:
| |
Part 17 - Extension methods: enriching types you don’t own
Why. In Scala, implicit class lets you add methods to any existing type without modifying it:
| |
Python doesn’t have implicit conversions, but there are two clean patterns that give you most of this with full type safety.
Option A: typed wrapper class. Wraps the original type, exposes only the methods you care about:
| |
Option B: module-level typed functions. No wrapper, just well-typed utility functions. This is the more Pythonic form and equally type-safe:
| |
When the wrapper pays off. Use RichStr when you’re chaining multiple operations and want a fluent style - RichStr(value).to_snake_case().upper() reads better than nested function calls. Use plain functions when you’re not chaining.
Extending with Protocol. If you want multiple types to support the same enrichment, define the Protocol for the base capability and write functions against it:
| |
Part 18 - Recursive generics: type-safe trees and collections
Why. A tree node that holds Any children is not a tree - it’s a risk. Scala’s recursive ADTs are precise about what a node contains:
| |
Python handles this with recursive Generic types. The key is using from __future__ import annotations (or string literals) to break the forward reference cycle:
| |
Recursive functions on the tree are type-safe:
| |
Same pattern for linked lists, expression trees (ASTs), JSON values - anything with recursive structure. mypy follows the generic parameter through all levels.
Part 19 - Type class derivation: Comparable, Serializable, Hashable by contract
Why. In Scala, type classes like Ordering[T], Eq[T], and Show[T] are defined independently of the types they operate on. You provide an instance, the system uses it. Python’s Protocol + ABC gives you the same separation.
The goal is a Sorter or JsonWriter that works for any type that provides the right capability, without those types knowing about the sorter:
| |
For parametric instances (type class for list[T] given an instance for T), use Generic:
| |
Scala 3’s given/using vs Python. Scala 3 can inject type class instances automatically. Python can’t - you pass them explicitly. But because Protocol is structural, you rarely need to pass anything at all: if the type already has the method, it qualifies. The runtime cost of isinstance(obj, JsonWritable) check is O(1).
Part 20 - Reader monad: dependency injection without globals
Why. Real systems have configuration, loggers, database handles, clocks. Yeah, you know the pattern - config.py imported everywhere, tests that monkey-patch module-level state, surprise failures when the import order is wrong. The Scala approach threads a reader environment R through every function that needs it and keeps the functions pure:
| |
Python has no native Reader type, but a frozen dataclass with a callable field - or simply functions that take config explicitly - achieves the same effect. The key is that the config is never mutated and never stored in a global:
| |
Usage - the environment flows down without any global access:
| |
The DI payoff. fetch_user and fetch_account are pure functions. No import config at the top. No thread-local. No monkey-patching in tests. In tests, pass a different AppConfig with a test database URL. Done.
Part 21 - Writer monad: pure logging alongside computation
Why. Logging in Python typically means side effects - print() or logging.getLogger() inside business logic. That contaminates pure functions and makes testing harder.
Scala’s Writer[W, A] carries a log alongside the value, purely:
| |
Python translation:
| |
Pure business logic that produces a structured audit trail:
| |
The entire computation is pure. The log is a value. You test it by asserting on result.log - no log capturing infrastructure needed.
Part 22 - State monad: pure stateful computation
Why. Counters, accumulators, IDs - state that threads through a computation. The mutable approach scatters state updates throughout the call stack. Scala’s State[S, A] makes it explicit:
| |
Python:
| |
Assign unique IDs to nodes without mutating any shared counter:
| |
When to prefer State over plain mutation. When the state needs to be rolled back, replayed, or tested in isolation, State makes that trivially easy - pass a different initial state. With shared mutable state, rollback means saving and restoring snapshots.
Part 23 - Semigroup and Monoid: algebraic combining
Why. Combining things is everywhere: merging configs, accumulating errors, concatenating logs, summing metrics. Most Python code handles this with ad-hoc if x is None: ... else: ... logic scattered in every loop. Look, it works - until you have twelve things to combine and six different combining strategies.
Scala’s Cats library encodes this as a law-governed abstraction:
| |
Python with Protocol:
| |
Concrete instances:
| |
Now write one generic fold that works for any Monoid:
| |
One function. Works for Metrics, ConfigFragment, error lists, anything that implements combine + empty. The abstraction pays off when you have many things to fold - you write the combining logic once, in the type, not in every loop.
Part 24 - F-bounded polymorphism: self-referential types
Why. Sometimes a method should return the type of the concrete subclass, not the base class. In Scala this is natural with F <: Sortable[F]:
| |
Python translation uses a bound TypeVar pointing to the implementing class:
| |
Now min_of preserves the concrete type through generics:
| |
Practical use cases. Domain objects that need ordering (Score, Timestamp, Version, Money), builder types that return Self from mutator methods, and any recursive generic that needs to preserve the subtype in its return position.
Python 3.11+ shortcut. The stdlib added Self in typing:
| |
Self is the simpler form of F-bounded polymorphism for fluent builder chains.
Python version compatibility: what works where
This table summarises everything above. Run python --version and pick your row:
| Feature | Module | Min version | Notes |
|---|---|---|---|
| Type hints on functions | typing | 3.5 | PEP 484 |
TypeVar, Generic | typing | 3.5 | |
NewType | typing | 3.5.2 | Became a class in 3.10 |
@dataclass | dataclasses | 3.7 | |
Protocol | typing | 3.8 | Use typing_extensions for 3.7 |
Literal | typing | 3.8 | Use typing_extensions for 3.7 |
TypedDict | typing | 3.8 | Use typing_extensions for 3.7 |
@final decorator | typing | 3.8 | |
Final, ClassVar | typing | 3.8 | |
@overload | typing | 3.5 | Works everywhere |
X | Y union syntax | built-in | 3.10 | Use Union[X, Y] before 3.10 |
match statement | built-in | 3.10 | PEP 634. No stdlib backport. |
TypeGuard | typing | 3.10 | Use typing_extensions for 3.9 |
TypeAlias | typing | 3.10 | |
slots=True in @dataclass | dataclasses | 3.10 | Manual __slots__ before |
Self type | typing | 3.11 | Use TypeVar("T", bound="MyClass") before |
Required/NotRequired | typing | 3.11 | typing_extensions for 3.9-3.10 |
type X = ... soft alias | built-in | 3.12 | PEP 695. Use TypeAlias before |
class Stack[T]: syntax | built-in | 3.12 | PEP 695. Use Generic[T] before |
Self type (F-bounded shortcut) | typing | 3.11 | Use TypeVar("T", bound="C") before |
Callable[[A], B] type alias | typing | 3.5 | type X = ... syntax in 3.12 |
Generic[S] phantom type builder | typing | 3.5 | No backport needed |
@classmethod smart constructor | built-in | 3.0 | Result[T, str] return type from 3.9+ |
Recursive Generic[A] (trees) | typing | 3.7 | from __future__ import annotations needed |
| Protocol composition | typing | 3.8 | Multiple Protocol inheritance works on 3.8+ |
Reader/Writer/State monads | dataclasses + typing | 3.7 | Pure Python, no library needed |
Semigroup/Monoid via Protocol | typing | 3.8 | functools.reduce for fold |
The minimum viable setup for all these patterns: Python 3.10 gets you match, TypeGuard, X | Y union syntax, Literal, Protocol, frozen dataclasses, and @final. That’s the version where this whole system clicks.
For teams stuck on 3.8 or 3.9: install typing_extensions. It backports everything except match and the X | Y syntax.
Putting it all together: a design session
Here’s a concrete example combining the core patterns on a single domain problem - a task scheduler - to show how they interact:
| |
Notice what this design prevents:
- Passing
Priority(11)raises at construction - never gets scheduled TaskRunnerprotocol is satisfied by any callable with the right signature - no base classrunner(task)returnsResult, forcing the caller to handle both outcomesmatchonOk/Erris exhaustive - add a thirdResultvariant and mypy tells you what’s missingTaskId("user-input-here")vsstr- cross-domain assignment is a type error
What you actually gain
These patterns don’t remove all bugs. Python’s runtime doesn’t enforce NewType. A sufficiently determined programmer can bypass any of this.
What they do:
Self-documenting signatures. def build(rate: Hertz, duration: Seconds) -> Result[Table, str] tells you units, that it can fail, and what success looks like. You read the signature once. You don’t read the implementation to understand what you’re calling.
Cheap refactoring. Change timeout: float to timeout: Seconds and mypy finds every call site that needs a cast. Not grep-and-hope - structured type-checking across the whole codebase.
Forced error awareness. Result is contagious. Once a function returns Result, its callers can’t accidentally swallow the error. The lazy path is the correct path - you have to do something with both Ok and Err.
Consistent team patterns. When the team agrees on frozen dataclasses for domain objects and Result for fallible logic, code review conversations shift from “did you handle the error?” to what actually matters. The patterns enforce the basic contract. You focus on the business logic.
TL;DR
NewTypemakes domain unit confusion a mypy error - zero runtime cost@dataclass(frozen=True)with__post_init__gives you validated immutable value types@finalonOk/ErrturnsResult[T, E]into a sealed sum typeProtocolwith@runtime_checkableis structural subtyping - no inheritance neededmatchon sealed types gives exhaustive dispatch; mypy flags missing casesLiteralrestricts string/int parameters to a fixed set at type-check timeTypedDictgives type-checked field access on external dict-shaped dataTypeVarwithcovariant=True/contravariant=Truemirrors Scala’s+T/-T@overloadlets you express different return types per input typeTypeGuardpropagates type narrowing through custom validation functionsFinalandClassVarmark constants as immutable at the type-checker levelGeneric[S]withLiteralphantom states enforces builder step ordering at check time- Smart constructors return
Result[T, str]- invalid objects become unrepresentable Valid[T]accumulates all validation errors;Resultfails fast - each has its place- Protocol composition enforces Interface Segregation without inheritance hierarchies
- Named
Callabletype aliases make function contract intent visible in signatures Readermonad threads environment/config purely; no globals, no monkey-patchingWritermonad carries a pure audit log alongside computation resultsStatemonad makes stateful logic rollback-safe and trivially testableSemigroup/Monoidvia Protocol gives a singlefoldthat works for any combinable type- F-bounded
TypeVar/Self(3.11+) preserves concrete type through builder chains - Minimum Python version for all patterns combined: 3.10 (3.11 for
Self) - No third-party dependencies - all of this is in
typing,dataclasses, andfunctools
References
Python official docs and PEPs
- PEP 484 - Type hints - the original type annotation proposal
- PEP 526 - Variable annotations
-
x: int = 5syntax - PEP 544 - Protocols (structural subtyping) - Python 3.8 Protocol
- PEP 586 - Literal types - Python 3.8 Literal
- PEP 589 - TypedDict - Python 3.8 TypedDict
- PEP 591 - Final qualifier - Python 3.8 Final/@final
- PEP 634 - Structural pattern matching - Python 3.10 match
- PEP 647 - TypeGuard - Python 3.10 TypeGuard
- PEP 695 - Type parameter syntax
- Python 3.12
class C[T]: - typing module docs - full stdlib reference
- mypy documentation - the type checker used throughout this article
Scala comparison references
- Scala 2 AnyVal value classes
- Scala 3 opaque types
- Scala sealed trait pattern
- Scala 3 type class derivation
Further reading
- typing_extensions - backports for Python 3.7-3.9
- pyright - Microsoft’s type checker, stricter than mypy on some generics
