ADR-0018: Type-safety adoption strategy
Status: Accepted
Date: July 2026
Context
Protean ships a py.typed marker, so downstream users rely on its annotations
for their own type-checking. But the framework's own source is not type-checked
in CI. A typed library that doesn't type-check itself gives users a weaker type
experience than it advertises.
The measured baseline (June 2026): ~700 mypy errors across 87 files under the
current lenient config, and ~2,400 under --strict. The only mypy in CI today
is tests/ext/, which checks the plugin against fixtures, not the source — so
none of the 700 errors are gated, and new type debt accrues freely.
Two constraints shape the approach:
- It cannot be a big-bang. A single 700-error (or 2,400-error) PR is unreviewable and would freeze feature work while it's in flight.
- The mypy plugin types user code, not the framework's own source. The
plugin (
src/protean/ext/mypy_plugin.py) makesString()resolve tostrand injects base classes for decorator-registered elements in downstream code. The framework's own modules must type-check on explicit annotations, independent of the plugin (except where the framework consumes its own field/element machinery).
Decision
Adopt type safety with a quarantine-then-ratchet strategy — the same path large codebases (e.g. Dropbox) used to adopt mypy.
-
Gate mypy in CI now, against a per-module quarantine.
[tool.mypy]carries a[[tool.mypy.overrides]]list of the currently-failing modules withignore_errors = true. A clean run is therefore green, and mypy blocks any new error in a non-quarantined module. The quarantine list is append-only-shrinking: every subsequent sub-issue strictifies its modules and deletes them from the list. The end state is an empty list andstrict = trueglobally. -
Enable strict flags by cost.
no_implicit_optionalis already on (free);warn_unused_ignoresis enabled now (cheap — it forced a one-time cleanup of staletype: ignores). The expensive levers (disallow_any_generics,disallow_untyped_defs, full--strict) are not global switches; they fall out naturally per-module as each quarantine entry is cleared. -
The plugin is independent of source strictness. Making the framework strict does not require plugin changes, except where the framework consumes its own field/element machinery — that plugin hardening happens alongside
fields/. -
py.typedis a contract. The public surface (__init__.pyexports,protean.fields,QuerySet/DSL,Domainmethods) gets first-class annotations so downstreamreveal_typeis accurate. -
Sequence respects coupling:
utils/port→fields(+plugin) →core→domain→adapters/server→cli/ir. Each step removes its modules from the quarantine and ships its own tests.
Consequences
- CI blocks new type debt immediately, while existing debt is an explicit, visible, shrinking allowlist rather than an invisible backlog.
- The migration proceeds incrementally without freezing feature work; each sub-issue is independently reviewable and ships tests.
- Downstream users get progressively more accurate types as the quarantine
shrinks, backed by the
py.typedcontract. - The quarantine is coarse:
ignore_errors = truesilences all errors in a quarantined module, so a regression inside a still-quarantined module is not caught until that module is strictified. This is an accepted trade for a green, enforceable gate today. - Reaching
strict = truetakes several sub-issues (D2–D6); the hardest (fields/,core/) require plugin and pydantic-interaction work, not just annotations.
Alternatives Considered
- Big-bang
--strict. Rejected: a 2,400-error PR is unreviewable and would freeze feature work; type quality would arrive as one high-risk drop instead of a ratchet. - Leave the source untyped. Rejected: shipping
py.typedwhile not type-checking the source misrepresents the type quality users receive. - Gradual typing with no CI gate. Rejected: without the gate there is no ratchet — new debt accrues as fast as old debt is paid down.