Type checkers in anger: narrowing, strictness, Any contagion, and mypy vs pyright
A checker verifies what your code proves: isinstance and TypeGuard/TypeIs narrowing, strict flags, no implicit Optional. Any spreads silently and erases checking. mypy and pyright differ in inference and speed; reveal_type shows what the checker believes. Run one in CI.
The kill switch did not kill anything. During the checkout incident, the on-call flipped new_checkout to off and watched error rates stay flat for nine minutes before bypassing the flag service entirely. The postmortem found the bug in eleven lines: def is_enabled(flag: str) -> bool: ended with return FLAGS.get(flag). For any flag missing from the cache — which the incident’s redeploy had just emptied — it returned None, not False. Every call site written as if is_enabled(...) worked by accident, because None is falsy. The incident dashboard, though, was written carefully: if is_enabled("new_checkout") is False: disable(). And None is False is never true. mypy in strict mode flags that return line verbatim — Incompatible return value type (got "bool | None", expected "bool") — and the team even had hints everywhere. They had just never run a checker on them. The annotation was a promise; nothing in the pipeline made it a proof.
Narrowing: what a checker can actually prove
A checker is a theorem prover over your control flow. Given value: str | bytes | None, it tracks how each branch shrinks the union: an if value is None: return eliminates None for the rest of the function; isinstance(value, bytes) splits the remainder; a walrus if (m := pattern.match(s)): narrows m to Match[str] inside the block; match statements narrow per case arm. When built-in narrowing can’t see through your helper functions, you teach it: TypeGuard (PEP 647) declares that a boolean function proves a type in its true branch, and TypeIs (PEP 742, Python 3.13) is the better-behaved successor that narrows both branches:
from typing import Any, TypeGuard
def is_str_list(xs: list[Any]) -> TypeGuard[list[str]]:
return all(isinstance(x, str) for x in xs)
def handle(value: str | bytes | None) -> str:
if value is None:
return ""
if isinstance(value, bytes):
reveal_type(value) # note: Revealed type is "bytes"
return value.decode()
reveal_type(value) # note: Revealed type is "str"
return valuereveal_type() is the debugger for all of this: a fake function only the checker understands (it errors at runtime in older Pythons; 3.11+ ships a real one in typing), printing what the checker believes a value to be at that exact line. When a checker rejects code you “know” is fine, the productive move is not arguing — it is inserting reveal_type and discovering the union still contains a member you thought you had eliminated. The sharp edge of TypeGuard: the checker trusts your implementation blindly. A guard that returns True for the wrong shape is a hole punched in the proof, with the checker’s full confidence behind it.
Any is the contagion; strictness is the quarantine
Before you declare “80% annotation coverage” a success, ask yourself: where does your data actually enter the codebase? If a third-party client or an untyped helper sits at the base of the call chain, that coverage number may be covering almost nothing.
Any is compatible with everything in both directions — and it spreads. A value from an untyped library is Any; index it, add to it, pass it along, and every result is Any too. Code paths touched by Any are simply unchecked, while looking identical to checked code. This is why “we have 80% annotation coverage” can mean far less than it sounds: one untyped client at the bottom of the call chain quietly turns whole modules back into dynamic Python. Strictness flags are the quarantine. mypy’s --strict bundles the important ones: --disallow-untyped-defs (every function must be annotated), --disallow-any-generics (no bare list), --warn-return-any, and implicit-Optional is already rejected by default in modern mypy — the Hook’s -> bool returning None dies right there. Pyright’s strict mode is the equivalent, plus reportUnknownMemberType-style diagnostics that surface Any leaking from libraries instead of silently accepting it. Nobody turns full strict on a legacy codebase in one day; the working pattern is the per-module ratchet — strict for new and boundary modules, basic elsewhere, and the config only ever tightens.
A function fetches data via an untyped client library, so the checker sees the result as Any. What does the checker verify about the code that uses this result?
mypy vs pyright, and making the proof a CI gate
The two production checkers agree on the core but diverge usefully. mypy is the reference implementation: Python-based, slower on large codebases (the dmypy daemon makes warm re-checks fast), with a plugin system that understands Django and SQLAlchemy magic — often the deciding factor in those stacks. pyright is Microsoft’s checker in TypeScript, the engine inside Pylance/VS Code: substantially faster on big repos (full runs that take mypy minutes commonly finish in seconds-to-tens-of-seconds), with watch mode, and noticeably stronger inference — pyright infers return types of unannotated functions and checks their bodies, where mypy by default treats unannotated functions as Any and skips them. That single difference means pyright finds real bugs in code mypy never reads, and also why teams adopting pyright see a wave of new errors on day one. Honest guidance: either is far better than none; pyright for speed and inference, mypy for plugin-dependent ORMs; running both is reasonable since they catch non-overlapping issues. Whichever you pick, it must be a CI gate — checker-on-laptop-only is how the Hook happens. The standard setup: run on every PR with caching, fail the build on any new error, and pin the checker version so upgrades (which add checks) land as deliberate PRs, not surprise red builds.
A file contains def f(): return 42 with no annotations, and a caller does f() + 'x'. What do mypy (defaults) and pyright report?
- 01How does narrowing work, what do TypeGuard and TypeIs add, and how do you debug a narrowing that is not happening?
- 02Why is Any called the contagion, and what concretely differs between mypy and pyright when they meet unannotated code?
A type checker is a prover over your control flow, and it can only prove what the code makes provable. Unions narrow branch by branch — is None guards, isinstance, walrus bindings, match arms — and custom predicates join the proof through TypeGuard (PEP 647, true-branch only, trusted blindly) and TypeIs (PEP 742, both branches). reveal_type is the debugger: it prints the checker’s belief at a line and usually reveals the union member you failed to eliminate. The enemy of the proof is Any: compatible with everything, propagated through every operation, it silently switches checking off across whole code paths while the code still looks typed — one untyped client can undo a module’s worth of annotations. Strictness flags are the quarantine — disallow-untyped-defs, warn-return-any, no implicit Optional (the exact check that catches the Hook’s kill-switch bug, a -> bool returning None that made is False never fire) — applied as a per-module ratchet on legacy code. mypy and pyright share the core but differ where it matters: pyright infers and checks unannotated functions and runs substantially faster; mypy skips unannotated bodies by default but brings the plugin system Django and SQLAlchemy stacks rely on. Both are only promises until CI runs them: gate every PR, fail on new errors, pin the checker version so upgrades land deliberately. Now when you see a -> bool that returns something falsy-but-not-False, you know exactly which checker flag would have caught it — and exactly what to add to CI so the next engineer doesn’t spend nine minutes watching error rates stay flat.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.