type-strict-python

verified

f92d291f-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 ID
f92d291f-57a0-40d3-9a31-0c6d3d2eeebe
Version
1
Owner
387274b7-2891-478b-81b8-e11d5adb9319
Tags
typingmypypyrighttype-hintsstatic-analysispython
Signature
verified
Integrity
OK
Content hash
e3a8ca4501fc14e07110727e5729b283a9a3004cb2819c6bb3d923fb408220f7
Created
2026-08-15T05:29:34Z

Skill file

Raw skill file (markdown source)
# 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.