Module 3
Typing, dataclasses & protocols
Python is dynamically typed, and annotations don't change that. def f(x: int) -> str runs exactly the same with or without the hints — the interpreter stores them and otherwise ignores them. So what are they for? Type hints are documentation the tooling enforces. A checker like mypy or pyrightruns in CI and fails the build when a call site and a signature disagree — turning “the docstring drifted out of date” into a red build instead of a 2 a.m. page. No runtime cost, because nothing happens at runtime.
The modern syntax is compact:
def realized_beta(
asset: list[float], # built-in generics: list[str], dict[str, float]
market: list[float],
window: int = 252,
) -> float: ...
price: float | None = None # "float or None" — replaces Optional[float]
class BetaResponse(TypedDict): # the shape of a JSON-ish dict, checkable
ticker: str
beta: float
window: intThe senior stance: hints at module boundaries are non-negotiable in team code — public functions, class fields, anything another file imports. Inside a ten-line helper where the types are obvious? Judgment call. The boundary is where the next engineer (or six-months-later you) reads the contract instead of the implementation.
@dataclass — Python's answer to Java records
Declare the fields, and @dataclass generates __init__, __eq__ and __repr__for you — the same “transparent carrier of data” idea as the Java records we used in the FE track's records module. Add frozen=True and assignment after construction raises — immutability. And __post_init__ is the validation gate: it runs right after the generated __init__, so a bad record never exists:
from dataclasses import dataclass
import datetime
import math
@dataclass(frozen=True, slots=True)
class PriceRecord:
date: datetime.date
ticker: str
close: float
def __post_init__(self) -> None:
# frozen=True blocks normal assignment even here, so sanctioned
# normalization goes through object.__setattr__ — the one idiom
object.__setattr__(self, "ticker", self.ticker.strip().upper())
if not self.ticker:
raise ValueError("ticker must not be blank")
if not (math.isfinite(self.close) and self.close > 0):
raise ValueError(f"closePrice must be positive and finite, got {self.close}")Three details seniors reach for: frozen=True makes instances safe to share and cache (mutation raises FrozenInstanceError); the object.__setattr__ call is the standard escape hatch for normalizing a frozen field inside __post_init__; and slots=True replaces the per-instance __dict__ with fixed slots — meaningfully less memory when you hold millions of rows.
🎛 Frozen dataclass validator
Constructed — __post_init__ passed
PriceRecord(date=datetime.date(2024, 6, 3), ticker='MSFT', close=417.32)
date
2024-06-03
ticker (normalized)
MSFT
close
417.32
This mirrors a frozen dataclass whose __post_init__ normalizes the ticker (via object.__setattr__, the sanctioned idiom for frozen fields) and rejects bad prices — so construction either yields a fully valid, immutable row or raises. The same gate the Java service runs in its record's compact constructor; here the freeze also makes instances safe to share across threads and caches.
Protocols — duck typing, formalized
Python culture says “if it quacks, it's a duck”: any object with the right methods will do. typing.Protocol writes that down so the checker can enforce it — an interface satisfied by shape, not by inheritance. Anything with a matching estimate() method conforms, whether or not it has ever heard of the protocol:
from typing import Protocol
class BetaEstimator(Protocol):
def estimate(self, asset: list[float], market: list[float]) -> float: ...
class WelfordEstimator: # no inheritance, no import of the protocol
def estimate(self, asset: list[float], market: list[float]) -> float:
...
def run(est: BetaEstimator) -> float: # accepts anything with a matching estimate()
return est.estimate(asset, market)This is structural typing; an ABC (abc.ABC) is nominal — conformance by explicit subclassing, checkable at runtime with isinstance. Protocols shine at the edges of a codebase (accept anything shaped right, including classes you don't own); ABCs shine inside one (a closed family with shared behavior). If this pattern looks familiar, it should: it's the Python twin of the Java BetaEstimator strategy interface from the FE track's composition & DI module — same design, minus the ceremony.
Enums, and the pydantic boundary
For a closed vocabulary — window sizes, order sides, calculation modes — use an Enum rather than magic strings. Typos become AttributeError at import time instead of silent misroutes, and exhaustiveness is checkable:
from enum import Enum
class Window(Enum):
MONTH = 20
YEAR = 252
FIVE_YEAR = 1260One line on pydantic: it does what dataclasses do plus runtime validation and coercion, which is exactly what you want at system boundaries — parsing untrusted JSON in an API handler. The common senior split: pydantic models at the edges, plain (frozen) dataclasses for everything inside, where the type checker already guards the types.
Things to try
- • Type a lowercase ticker like msft — the constructed repr shows MSFT. That's __post_init__ normalizing through object.__setattr__.
- • Set close to -5 — construction raises ValueError, so no invalid record ever exists downstream.
- • Press try rec.close = 0 with frozen=True — you get the real FrozenInstanceError.
- • Now toggle without frozen=True and mutate again — it silently succeeds. Imagine that record sitting in a shared cache.