type-strict-python
verifiedf92d291f-57a0-40d3-9a31-0c6d3d2eeebe
Adopt strict static typing in Python — full annotations, no Any, mypy/pyright in strict mode, and gradual migration. Use when a Python codebase is growing and needs type safety.
Metadata
Skill file
# Type-Strict Python
Use when a Python codebase is growing, adding engineers, or accumulating bugs that
a type checker would have caught (`None` dereferences, wrong dict shapes, bad
returns). The goal: full annotations, no `Any`, and `mypy --strict` (or pyright
strict) green in CI.
## 1. Baseline setup
```bash
pip install mypy
# pyproject.toml
# [tool.mypy]
# strict = true
# warn_unreachable = true
# or pyright
pip install pyright
# pyproject.toml -> [tool.pyright] strict = ["**/*.py"]
```
Wire it into pre-commit and CI so regressions cannot land:
```yaml
# .pre-commit-config.yaml
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.10.0
hooks:
- id: mypy
args: [--strict]
```
```bash
mypy --strict src/ # the command that must stay green
pyright # equivalent for pyright
```
## 2. The `Any` escape hatch — and how to remove it
`Any` is the enemy of type safety: it silently disables checking wherever it
flows. Every `Any` must be replaced or justified.
| Replace `Any` with | When |
|---|---|
| A concrete type | You know the type — just write it |
| `Protocol` | You need "anything with these methods" (structural typing) |
| `TypeVar` | The type varies but is consistent across the function |
| `cast(T, x)` + comment | You know better than the checker, and explain why |
| `object` | The value is genuinely opaque |
```python
# BEFORE — Any leaks and disables checking
def process(payload: Any) -> Any:
return payload["items"].count()
# AFTER — Protocol expresses the real contract
from typing import Protocol
class HasItems(Protocol):
items: list[str]
def process(payload: HasItems) -> int:
return payload["items"].count(x) # type-checked
```
`cast` is the last resort, and always carries a comment:
```python
# We validated the shape above; cast documents intent for the checker
user = cast(User, session.get("user"))
```
## 3. Gradual migration playbook
Do not try to make a legacy codebase `--strict` in one pass. Migrate in stages:
1. **New modules first** — all new code is fully typed and `strict` from day one.
2. **Enable `disallow_untyped_defs`** — forces annotations on all function
signatures without demanding full strictness elsewhere.
3. **Ratchet the count down** — track `mypy --strict | grep -c error` and never
let it increase.
4. **Flip `strict` on** once the error count hits zero per-module.
```bash
# Stage 2: require annotations everywhere, lenient elsewhere
mypy --disallow-untyped-defs src/
# Ratchet: fail CI if the error count rises
errs=$(mypy --strict src/ 2>&1 | grep -c 'error:')
test "$errs" -le "$MAX_ALLOWED" || { echo "type errors increased"; exit 1; }
```
## 4. Working with third-party libs (no stubs)
For libraries without type stubs, prefer typed stubs over `Any`:
```bash
# many packages ship stubs now; otherwise use types-* packages
pip install types-requests types-python-dateutil
```
For a genuinely untyped internal module, add a `.pyi` stub or a typed wrapper at
the boundary — never let the `Any` leak into the rest of the code.
## 5. Common mypy/pyright errors and their fixes
| Error | Meaning | Fix |
|---|---|---|
| `Cannot determine type of 'x'` | Missing annotation on a variable/arg | Add the type |
| `Item "None" of "Optional[T]" has no attribute` | Possible `None` deref | Add a guard or `assert x is not None` |
| `Incompatible types in assignment` | Wrong type assigned | Fix the value or the annotation |
| `Argument 1 has incompatible type "str"; expected "int"` | Caller passes wrong type | Fix the caller, not the signature |
| `Need type annotation for 'x'` | Inference impossible (usually empty container) | Annotate `x: list[str] = []` |
| `X has no attribute "y"` | Missing type stub or wrong type | Add stub, or narrow with `isinstance` |
Do not silence these with `# type: ignore` without a reason. An ignore is a
promise to the next reader that the checker is wrong — write *why* next to it:
```python
x = legacy_call() # type: ignore[no-untyped-call] # vendored lib has no stubs
```
## 6. The runtime check vs static check boundary
Type hints are *not* enforced at runtime. If a type contract matters at a boundary
(user input, external data), validate explicitly:
```python
# Types keep the checker happy; validation keeps runtime correct.
def create_user(raw: dict) -> User:
# static type: raw is dict[str, Any]
name = raw["name"]
if not isinstance(name, str): # runtime guard, not just a hint
raise InvalidInputError("name must be a string")
return User(name=name)
```
Static typing catches *your* mistakes; runtime validation catches *the world's*
inputs. You need both at the edges.
## Guardrails
- Do **not** spam `cast()` to silence errors — that defeats the purpose; fix the
underlying type instead.
- Do **not** use `Any` when a `Protocol` or `TypeVar` expresses the contract.
- Do **not** write over-generic `Protocol`s that type-check but are unsound (a
`Protocol` with no members matches everything — that is just `Any` in disguise).
- Do **not** annotate trivially inferred variables (`x: int = 5`) — the checker
already knows; annotation noise is a distraction.
- Do keep `strict` (or equivalent) enforced in CI, not just locally.
## Pitfalls
- **Type-cast spam** — `cast()` everywhere makes the checker green while hiding
real type bugs; it is a code smell, not a solution.
- **Over-generic Protocols** — `class Thing(Protocol): ...` with no members
matches every type, so nothing is actually checked.
- **`Any` leaks** — one `Any` at a boundary propagates unchecked through every
function that touches it.
- **Big-bang migration** — trying to make a 100k-line codebase strict in one PR
stalls forever; migrate incrementally.
- **Ignoring the checker** — running mypy but not in CI means it will rot.
## Verify / Checklist
- [ ] `mypy --strict src/` (or pyright) returns zero errors.
- [ ] `grep -rn 'Any' src/` returns nothing (or only justified, commented uses).
- [ ] `cast()` usages each carry a comment explaining why.
- [ ] Type checking runs in CI and pre-commit, not just manually.
- [ ] New modules are fully typed; legacy modules are ratcheting toward strict.
- [ ] Third-party deps use `types-*` stubs or typed wrappers, not `Any`.
Attached files
No attached files.