Python Typing skill for Codex

Strict type checks without drowning in Any. Codex picks up the project's existing checker, enables strict mode globally and loosens only named legacy modules, then works leaf-first until the override list is empty. It writes PEP 695 generics when the Python version allows, uses Protocols and ParamSpec instead of Any, narrows with TypeIs and assert_never, and leaves every remaining ignore with an error code and a reason.

  • Languages
  • $python-typing
  • 240 lines
  • 2 files
  • ~2,200 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- python-typing

Installs to ~/.agents/skills/python-typing/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $python-typing, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Adds and tightens Python type hints checked by mypy or pyright - strict configs, per-module ratchets, generics with PEP 695 syntax, Protocols, TypedDict, overloads, ParamSpec and narrowing with TypeIs. Use when a user asks to add types to untyped code, turn on mypy --strict or pyright strict, fix type checker errors, type a decorator or generic container, or replace Any and type: ignore comments with real types.

Use it when

  • An untyped or partly typed Python package needs annotations
  • You are enabling mypy --strict or pyright strict mode
  • A generic container, decorator or plugin interface needs precise types
  • Type checker errors pile up after a dependency or Python upgrade

Use something else when

  • You need runtime validation of input data rather than static checks: use pydantic-v2
  • The task is linting and formatting rules rather than types: use ruff-setup

How to check the result

The checker passes at the configured strictness in CI, the error count did not rise, and the test suite still passes unchanged.

The mistake it prevents

Silencing errors with Any or bare type: ignore comments, which hides exactly the bugs strict mode was meant to catch.

~/.agents/skills/python-typing/SKILL.md

--- name: python-typing description: Adds and tightens Python type hints checked by mypy or pyright - strict configs, per-module ratchets, generics with PEP 695 syntax, Protocols,… license: MIT ---

Done well means: the checker runs in CI at a declared strictness, every public function has complete annotations, Any appears only at real boundaries (untyped third-party code, raw JSON), and every remaining type: ignore carries an error code and a reason.

Before changing anything

  1. Read AGENTS.md and pyproject.toml for the existing checker, its config section ([tool.mypy], [tool.pyright], pyrightconfig.json, mypy.ini) and the run command.
  2. Find the minimum Python version (requires-python). It decides which syntax is legal:
FeatureNeeds
list[int], dict[str, X] at runtime3.9
X | None at runtime3.10
Self, Required/NotRequired, assert_never, LiteralString3.11 (else typing_extensions)
def f[T](x: T), class Box[T], type Alias = ... (PEP 695), @override3.12
TypeIs, ReadOnly, type parameter defaults (PEP 696)3.13
Annotations evaluated lazily by default (PEP 649/749)3.14
  1. Run the checker once and save the baseline count: mypy src | tail -1 or pyright --outputjson | python3 -c "import json,sys;print(json.load(sys.stdin)['summary'])".
  2. Install missing stubs only with approval if network is off: types-requests, pandas-stubs, or mypy --install-types --non-interactive (needs network).

Choosing a checker

SituationDefault
Repo already uses oneKeep it. Never add a second checker in the same change
New project, CLI and CI focusmypy with strict = true
Editor-heavy team on VS Code / Pylancepyright (or basedpyright) with typeCheckingMode = "strict"
Want very fast feedback on a large codebaseAstral ty or Meta pyrefly are usable but still young; keep mypy or pyright as the CI gate

Strict config with a ratchet

Turn strict on globally and loosen legacy modules explicitly. New code is strict on day one; the override list only shrinks.

[tool.mypy]
python_version = "3.12"
strict = true
warn_unreachable = true
enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"]
files = ["src", "tests"]

[[tool.mypy.overrides]]
module = ["myapp.legacy.*", "myapp.scripts.*"]
disallow_untyped_defs = false
disallow_incomplete_defs = false
check_untyped_defs = true

[[tool.mypy.overrides]]
module = ["some_untyped_lib.*"]
ignore_missing_imports = true
[tool.pyright]
include = ["src", "tests"]
pythonVersion = "3.12"
typeCheckingMode = "strict"
reportMissingTypeStubs = "warning"
executionEnvironments = [{ root = "src" }]
# per-file opt-out while migrating: put "# pyright: basic" at the top of a legacy file

Order of work on an untyped package: leaf modules first (utils, models), then services, then entry points. Each module leaves the override list in the same PR it becomes clean.

Annotation rules

  • Annotate every parameter and return of public functions, including -> None.
  • Accept abstract, return concrete: parameters take Iterable[T], Mapping[K, V], Sequence[T]; returns give list[T], dict[K, V].
  • Use X | None, never bare defaults like def f(x: int = None).
  • Prefer object over Any for “anything, and I will not touch it”. Any silently disables checking; object forces narrowing before use.
  • Raw JSON: parse into a TypedDict or a Pydantic model at the boundary instead of passing dict[str, Any] through the codebase.
  • Constants: Final. Closed string sets: Literal["asc", "desc"] or an enum.StrEnum.

Generics (3.12+ syntax first)

from collections.abc import Callable, Iterable

def first[T](items: Iterable[T], default: T) -> T:
    for item in items:
        return item
    return default

class Registry[K, V]:
    def __init__(self) -> None:
        self._items: dict[K, V] = {}

    def register(self, key: K, value: V) -> None:
        self._items[key] = value

type Handler[E] = Callable[[E], None]   # generic type alias

def clamp[N: (int, float)](x: N, lo: N, hi: N) -> N:   # constrained
    return max(lo, min(x, hi))

Pre-3.12 equivalent: T = TypeVar("T") and class Registry(Generic[K, V]). Do not mix the two styles in one class.

Rules: a type parameter used only once in a signature is a smell (use the concrete type or object). Use a bound ([T: Comparable]) when you call methods on T.

Protocols instead of base classes

from typing import Protocol, runtime_checkable

class SupportsClose(Protocol):
    def close(self) -> None: ...

def shutdown(resources: list[SupportsClose]) -> None:
    for r in resources:
        r.close()

Any class with a matching close satisfies this without inheriting. Add @runtime_checkable only when you need isinstance, and know it checks method names, not signatures.

Decorators keep signatures with ParamSpec

import functools
from collections.abc import Awaitable, Callable

def retry[**P, R](times: int) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[R]]]:
    def wrap(fn: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
        @functools.wraps(fn)
        async def inner(*args: P.args, **kwargs: P.kwargs) -> R:
            for attempt in range(times):
                try:
                    return await fn(*args, **kwargs)
                except ConnectionError:
                    if attempt == times - 1:
                        raise
            raise AssertionError("unreachable")
        return inner
    return wrap

Use Concatenate[Request, P] when the decorator injects or strips a leading argument.

Narrowing

from typing import TypeIs, assert_never

def is_str_list(val: list[object]) -> TypeIs[list[str]]:
    return all(isinstance(x, str) for x in val)

def area(shape: Circle | Square) -> float:
    match shape:
        case Circle(radius=r):
            return 3.14159 * r * r
        case Square(side=s):
            return s * s
        case _:
            assert_never(shape)   # adding a new shape type becomes a type error here

Prefer TypeIs (3.13 / typing_extensions) over TypeGuard: it narrows in both the true and false branches.

Overloads for return types that depend on arguments

from typing import Literal, overload

@overload
def load(path: str, *, raw: Literal[True]) -> bytes: ...
@overload
def load(path: str, *, raw: Literal[False] = ...) -> str: ...
def load(path: str, *, raw: bool = False) -> str | bytes:
    data = open(path, "rb").read()
    return data if raw else data.decode()

Suppressions

  • Always scoped: # type: ignore[arg-type] # lib stub wrong, fixed in v3 or # pyright: ignore[reportArgumentType]. strict in mypy includes warn_unused_ignores, so stale ignores fail the build.
  • cast() is an assertion you cannot test. Prefer an isinstance check or TypeIs helper; use cast only where the runtime check is too expensive, with a comment.
  • TYPE_CHECKING imports break circular imports; with from __future__ import annotations (or 3.14 lazy annotations) the names are not needed at runtime. Libraries that read annotations at runtime (Pydantic, FastAPI, dataclasses with field types) still need the real import.

Gotchas

  • list[int] is not assignable to list[float] (invariance). Take Sequence[float] in parameters that only read.
  • dict.get returns V | None; strict mode will flag every unguarded use. Narrow once.
  • Mutable default def f(x: list[int] = []) type-checks fine and is still a bug.
  • reveal_type(x) is the fastest debugging tool; remove it before committing (pyright and mypy both report it).
  • py.typed marker file is required for a package to export its inline types to users.
  • Typing tests too (files = ["src", "tests"]) catches fixture misuse early, but give tests a looser override if the user wants speed over rigor.

Verification

mypy                    # or: pyright / basedpyright
mypy --strict src/myapp/newmodule.py   # spot-check a module claimed clean
python -m pytest -q     # annotations must not change runtime behavior
  • Error count is at or below the baseline; no new Any in public signatures (mypy --disallow-any-expr on one module, or grep for : Any).
  • If an error resists, reduce it to a 10-line snippet and run the checker on that alone before adding an ignore.

Report template

**Checker:** mypy 1.x, strict on; 3 modules remain in overrides (legacy.billing, legacy.csv, scripts.*)
**Errors:** 412 -> 0 in strict scope
**Ignores:** 4, all coded, listed with reasons in the PR description
**Runtime check:** pytest 238 passed; no behavior changes
**Next:** move legacy.csv out of overrides (est. 30 errors)

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Python Typing"
  short_description: "Strict type checks without drowning in Any"
  default_prompt: "$python-typing Turn on mypy strict for this repo without breaking CI on day one"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem