Type system & static typing
SeniorProtocol vs ABC, variance in generics, TypedDict vs dataclass vs pydantic, and from __future__ import annotations.
Created 2 September 2026 · Updated 16 September 2026
5 concepts · detail hidden
01`Protocol` (structural) vs `ABC` (nominal) typing — and the runtime difference
#c-protocol-structural-vs-abc-nominal-typing-and-the-runtime-diNominal typing means “does this object formally, explicitly declare that it belongs to this type” (via inheritance) — an
ABC(Abstract Base Class, from theabcmodule) works this way: a class must explicitly subclass it (or be registered as a “virtual subclass”) to count. Structural typing means “does this object have the right shape — the right methods/attributes — regardless of what it formally inherits from,” which is exactly Python’s native duck-typing philosophy formalized for static type checkers;typing.Protocol(added in 3.8, PEP 544) lets you declare a structural interface (“anything with a.read()method returningstrcounts as this type”) that a class satisfies automatically just by having matching methods, with no inheritance required at all. The actual runtime difference: a plainProtocolcosts essentially nothing at runtime — it’s purely a hint for static type checkers like mypy/pyright and has no runtime enforcement by default; only if you add@runtime_checkabledoesisinstance()against it become meaningful at runtime, and even then it only checks that the required method/attribute names exist, not that their signatures actually match — a real, easy-to-miss limitation worth knowing.02Variance in generics — covariance vs contravariance vs invariance
#c-variance-in-generics-covariance-vs-contravariance-vs-invariaVariance describes how a generic type’s subtyping relationship behaves relative to its underlying type parameter’s subtyping relationship. Say
Dogis a subtype ofAnimal. Covariance would meanlist[Dog]is safely treatable as alist[Animal]— but this is actually unsound for a mutable container likelist: if you could pass alist[Dog]somewhere expectinglist[Animal], that code could legally insert aCat(also anAnimal) into what’s actually aDog-only list, corrupting it — this is exactly whylistis correctly treated as invariant (neither co- nor contravariant) by type checkers, while genuinely immutable/read-only containers (like a hypothetical read-only sequence) can safely be covariant, since nothing can be inserted into them after the fact. Contravariance runs the opposite direction and shows up with function parameter types: aCallable[[Animal], None](a function that can handle anyAnimal) can safely be used anywhere aCallable[[Dog], None]is expected (since it can certainly still handleDogs specifically) — but not the reverse, since a function that only knows how to handleDogs specifically can’t safely be substituted somewhere expecting to handle arbitraryAnimals.TypeVarlets you declare this explicitly viacovariant=True/contravariant=Truewhen defining your own generic classes.03`TypedDict` vs `dataclass` vs `pydantic.BaseModel` — validation, runtime cost, serialization
#c-typeddict-vs-dataclass-vs-pydanticbasemodel-validation-runtiTypedDictdescribes the expected shape of a plaindictfor static type-checking purposes only — it adds zero runtime enforcement or behavior; at runtime it’s just a regular dict, and nothing stops you from constructing one that doesn’t actually match the declared shape (a type checker would flag it, but the program runs anyway).dataclassgives you a real, structured object with actual attributes, auto-generated__init__/__repr__/__eq__, but performs no validation of the values passed in — it happily accepts a string where you annotated anint, since annotations aren’t enforced at runtime by plaindataclass.pydantic.BaseModeladds genuine runtime validation (rejecting/coercing values that don’t match the declared types) plus built-in serialization (converting to/from JSON, dicts, etc.) — at a real performance cost compared to a plain dataclass, though pydantic v2’s core validation logic being rewritten in Rust significantly narrowed that performance gap compared to the pure-Python v1 implementation, which is worth knowing if you’re weighing this tradeoff today rather than going on older folklore about pydantic being “slow.”04Why pydantic is so relevant in the modern Python ecosystem
#c-why-pydantic-is-so-relevant-in-the-modern-python-ecosystemPydantic’s core job is narrow — validate and coerce untyped input into typed, validated Python objects from a single declarative schema — but that one job sits at a genuinely central point in a lot of everyday Python: parsing untrusted data arriving at a process boundary (a JSON request body, environment variables and config files via
pydantic-settings, a database row, arguments coming back from an LLM tool call) into something the rest of the code can trust without re-checking. What makes it more than “just a validation library” is that the same class definition doubles as several other things for free: it generates JSON Schema, which is exactly the artifact FastAPI uses to produce OpenAPI docs, and increasingly the exact artifact used to describe an LLM tool/function-calling schema or a structured-output contract — so one schema, defined once, drives request validation, API documentation, and LLM-facing tool definitions simultaneously, rather than three separate things that would otherwise need to be kept in sync by hand. It also handles serialization (model ↔ dict ↔ JSON) as a first-class concern, not an afterthought bolted onto a plaindataclass. Pydantic v2’s core validation logic being rewritten in Rust (see theTypedDict/dataclass/pydantic.BaseModelcomparison above) removed most of the historical “pydantic is slow” performance objection, which is a large part of why it’s now reached for by default rather than reserved for cases that specifically needed validation.05`from __future__ import annotations` (PEP 563) — forward references and runtime introspection
#c-from-__future__-import-annotations-pep-563-forward-referenceNormally, a function/class’s type annotations are evaluated as real Python expressions at the moment the
def/classstatement runs — this causes a real problem for forward references (referring to a class/type that hasn’t been defined yet at that point in the file, or referring to the enclosing class itself from within its own method signatures), since the referenced name doesn’t exist yet and evaluating it would raise aNameError.from __future__ import annotations(implementing PEP 563) changes this: annotations are instead kept as unevaluated strings, only interpreted later, on demand, if something actually needs the real type object — this sidesteps the forward-reference problem entirely, since the annotation text is never eagerly evaluated. The real-world gotcha this creates: libraries that inspect type hints at runtime for their own purposes (FastAPI’s request/response validation, pydantic’s model validation) need to actively resolve those string annotations back into real type objects, typically viatyping.get_type_hints(), and this resolution step can itself fail or behave unexpectedly in certain forward-reference setups (e.g., a referenced name that’s genuinely never importable in the module’s own namespace at resolution time) — a subtlety that only surfaces once you’re building tooling that actually introspects annotations, not just writing them for a static checker’s benefit.