# Python Typing

> Python typing

- Skill: `stereobooster/python-typing` (Agent Skill, multi-file: 5 files)
- Install (CLI): `npx skillmds@latest add stereobooster/python-typing`
- Raw SKILL.md: https://api.skillmd.com/api/skills/stereobooster/python-typing/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: stereobooster (https://skillmd.com/u/stereobooster)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/stereobooster/python-typing

---


# Python typing

Correct how Python type annotations get written by default. Apply this when
adding, fixing, or reviewing types, or when adopting a checker. Target pyright
and mypy.

Types are an asset: once written, pydantic, Hypothesis (`from_type`), Typer,
FastAPI, and beartype read the same annotations for free, so precision pays
compounding returns. Do not treat annotations as decoration.

## Core principle

Fix looseness at the source, not at each use. When a value is `Any` or vaguely
typed, give it a precise type at its definition (a pydantic model, a `TypedDict`,
a type parameter), then let that type propagate. Do not cast at each consumer.
One typed source makes every downstream use type-check for free.

## Procedure

When adding types to a codebase or driving it toward strict, work in this order.

1. **Checker, stubs, version.** Configure pyright or mypy in strict mode. First
   detect the project's Python floor (`requires-python`) and pick syntax to match:
   3.12+ allows `def f[T]`, 3.13+ adds `TypeIs`; below that use `TypeVar`. Bump the
   floor only if the project allows it, do not assume it. Install missing stubs
   (see S3). Until imports stop resolving to `Any`, later work types against
   holes. Read `references/tooling.md` and `references/codegen-stubs.md`.
2. **Type the boundaries.** Parse untyped input through a pydantic model at every
   edge (S1). Generate models from a schema or sample where one exists (S4). Read
   `references/boundary.md`.
3. **Autoannotate, then ratchet.** Generate mechanical annotations, then track two
   counts that only fall: checker errors and type holes (`cast`, explicit `Any`,
   coercions). mypy skips unannotated bodies by default; set `check_untyped_defs`
   so the ratchet is honest. Read `references/tooling.md`.
4. **Grind, or be born strict.** Type the shared boundary reader first; its
   consumers become checkable for free. Write new modules strict from the start.

## The corrections

Each is a default the model produces and the fix. The `after` snippets are
verified clean under pyright + mypy + basedpyright.

### S1. Parse through the model at boundaries

The default reads `resp.json()`, `json.loads(...)`, or a DB row as `Any` and
indexes into it. `Any` hides the shape and the checker stays blind.

```python
# before: data is Any; data["name"] is unchecked
data = resp.json()
return data["name"]

# after: a typed object flows inward
user = User.model_validate(resp.json())
return user.name          # str
```

Set `model_config = ConfigDict(extra="forbid")` so renamed or misspelled fields
raise instead of being silently dropped (the default is `extra="ignore"`). In
pydantic v2, `Optional[T]` is a required field that accepts `None`; write `= None`
for a field that is actually optional. Read `references/boundary.md`.

### S2. Let the type flow with a type parameter

The default widens to `Any` or `object` and re-narrows with `isinstance`/`cast`.
Use a type parameter so the caller's type carries through.

```python
def first(items: list[Any]) -> Any: ...   # before: element type lost
def first[T](items: list[T]) -> T: ...     # after: type flows, first([1]) is int
```

This is a different case from S6: reach for a generic only when the code is
genuinely generic. Read `references/authoring.md`.

### S3. Install stubs before typing around a hole

When an import resolves to `Any`, install its stubs first. Some libraries ship
types inline (a `py.typed` marker), some need a `types-*` package, and for a
library that has types but does not expose them, generate a local stub with
`stubgen`. Do not annotate around a phantom `Any`. Distinguish this from a typed
library whose method legitimately returns `Any` (`duckdb` `fetchone()`,
`json.loads`): that is not a stub problem, so narrow or validate the value
instead of hunting for a stub package. Read `references/codegen-stubs.md`.

### S4. Generate types instead of hand-writing them

Hand-transcribed models drift and drop fields. Generate from the source of truth.

```
datamodel-codegen --input sample.json --input-file-type json \
  --output-model-type pydantic_v2.BaseModel
```

Use `stubgen` for stubs. Hand-write only what carries intent. Read
`references/codegen-stubs.md`.

### S5. Make illegal states unrepresentable

The default types a closed set as `str` or `int`. Use `Literal` or `Enum` for
closed sets, unions for either/or, discriminated unions for multi-shape payloads.
Pair with `assert_never` so an added variant fails the check.

```python
Status = Literal["ok", "error"]

def describe(s: Status) -> str:
    match s:
        case "ok":    return "all good"
        case "error": return "failed"
        case _:       assert_never(s)   # a new member breaks this
```

`assert_never` enforces exhaustiveness; `assert False` only suppresses the error.
Read `references/authoring.md`.

### S6. Name the shape instead of dict/object/Any

When a value has a known shape, name it. The tell is the access pattern: if you
read it by a literal key (`x["name"]`, `x.get("name")`), the keys are known at
authoring time, so name it; only variable-key reads (`x[k]`) justify `dict` or
`Mapping`. A value read by both a literal and a variable key is still partially
known: name the known keys and keep an explicit open tail for the rest. Banning
`Any` alone just pushes the default down the ladder to `object` then
`dict[str, object]`; the literal-key test catches that retreat.

```python
def make_user() -> dict: ...            # before: shape and value types lost

class User(TypedDict):                  # after
    name: str
    age: int
def make_user() -> User: ...
```

Use `TypedDict` for internal known-shape dicts (static, no runtime cost); use
pydantic at boundaries where you also validate at runtime. Read
`references/authoring.md`.

## Do not launder

Making a strict error vanish is not fixing it. `cast(...)`, `int()`/`float()`/
`bool()` around `dict.get(...)`, and bare `# type: ignore` buy no safety, and
`int()`/`float()` also truncate or produce `NaN` silently. Strict mode permits
explicit `Any`, so a module can pass strict while laundering. Narrow with
`isinstance`, or validate with a model, and scope every ignore to a specific
error code. Read `references/tooling.md`.

## Reference files

- `references/boundary.md` : parsing input, pydantic config, fetch/JSON, DB rows.
- `references/authoring.md` : generics, sum types, precise types, narrowing, decorators.
- `references/tooling.md` : checker config, strict flags, the ratchet, checker differences.
- `references/codegen-stubs.md` : installing stubs and generating types.

