Skip to main content
Technology & EngineeringPython Patterns202 lines

Type Hints

Type hint patterns and mypy best practices for statically typed Python code

Quick Summary28 lines
You are an expert in Python type hints and mypy for writing statically verifiable, self-documenting code.

## Key Points

- **Type annotations** on function signatures and variables declare expected types without enforcing them at runtime.
- **Generics** (`list[int]`, `dict[str, Any]`) parameterize container types.
- **`TypeVar`** and **`ParamSpec`** define generic functions and decorators that preserve type relationships.
- **`Protocol`** (structural subtyping) defines interfaces without inheritance.
- **`TypeAlias`**, **`TypeGuard`**, and **`TypedDict`** handle aliases, narrowing, and structured dicts.
- **`mypy --strict`** enables all optional checks for maximum safety.
- Use modern syntax (`X | Y` instead of `Union[X, Y]`, `list[int]` instead of `List[int]`) when targeting Python 3.10+.
- Use `from __future__ import annotations` on Python 3.9 to enable modern syntax via PEP 563.
- Run mypy in CI with `--strict` or at least `--warn-return-any --disallow-untyped-defs`.
- Use `Protocol` over ABC when you want structural ("duck") typing without forcing inheritance.
- Annotate public APIs fully; internal helper functions benefit too but are lower priority.
- Use `Final` for constants and `ClassVar` for class-level attributes to distinguish from instance attributes.

## Quick Example

```python
from typing import TypeAlias

JSON: TypeAlias = dict[str, "JSON"] | list["JSON"] | str | int | float | bool | None
Callback: TypeAlias = Callable[[int, str], None]
Matrix: TypeAlias = list[list[float]]
```
skilldb get python-patterns-skills/Type HintsFull skill: 202 lines
Paste into your CLAUDE.md or agent config

Type Hints — Python Patterns

You are an expert in Python type hints and mypy for writing statically verifiable, self-documenting code.

Overview

Python's type annotation system (PEP 484+) enables static type checking with tools like mypy, pyright, and pytype while preserving Python's dynamic runtime. Type hints improve code readability, catch bugs before runtime, and power IDE autocompletion. Modern Python (3.10+) has streamlined annotation syntax significantly.

Core Philosophy

Type hints in Python occupy a unique position: they are optional annotations that carry no runtime enforcement, yet they fundamentally change how you write, read, and maintain code. The value is not in catching every possible type error — Python's dynamism makes that impossible — but in making your code's contracts explicit. When a function declares def process(items: list[str]) -> dict[str, int], every reader (human and machine) immediately understands what goes in and what comes out.

Gradual typing is the key principle. You do not need to annotate everything at once, and you should not try. Start with your public APIs — the functions and classes that other modules import and call. These are the contracts that matter most and where type errors cause the most confusion. Internal helpers, closures, and throwaway scripts benefit from annotations too, but they are lower priority. The goal is to increase type coverage steadily over time, guided by the type checker's feedback.

Type hints should serve the developer, not the other way around. When you find yourself wrestling with complex generic types, nested Callable signatures, or circular import gymnastics just to satisfy the type checker, step back and ask whether the code design itself is too complex. Often, a difficult-to-type function is a function with too many responsibilities or an overly clever API. Let the type system guide you toward simpler, more explicit designs rather than treating it as an obstacle to work around.

Core Concepts

  • Type annotations on function signatures and variables declare expected types without enforcing them at runtime.
  • Generics (list[int], dict[str, Any]) parameterize container types.
  • TypeVar and ParamSpec define generic functions and decorators that preserve type relationships.
  • Protocol (structural subtyping) defines interfaces without inheritance.
  • TypeAlias, TypeGuard, and TypedDict handle aliases, narrowing, and structured dicts.
  • mypy --strict enables all optional checks for maximum safety.

Implementation Patterns

Basic annotations

def greet(name: str, times: int = 1) -> str:
    return (f"Hello, {name}! " * times).strip()

# Variable annotations
count: int = 0
names: list[str] = []
config: dict[str, int | str] = {}

Union and Optional (modern syntax)

# Python 3.10+
def find_user(user_id: int) -> User | None:
    ...

def process(value: int | str) -> str:
    if isinstance(value, int):
        return str(value)
    return value

TypeVar for generic functions

from typing import TypeVar, Sequence

T = TypeVar("T")

def first(items: Sequence[T]) -> T:
    return items[0]

# Bounded TypeVar
Numeric = TypeVar("Numeric", int, float)

def add(a: Numeric, b: Numeric) -> Numeric:
    return a + b

Protocol for structural typing

from typing import Protocol, runtime_checkable

@runtime_checkable
class Renderable(Protocol):
    def render(self) -> str: ...

class HtmlWidget:
    def render(self) -> str:
        return "<div>widget</div>"

def display(item: Renderable) -> None:
    print(item.render())

# HtmlWidget satisfies Renderable without inheriting from it
display(HtmlWidget())

TypedDict for structured dicts

from typing import TypedDict, NotRequired

class UserDict(TypedDict):
    name: str
    email: str
    age: NotRequired[int]

def create_user(data: UserDict) -> None:
    print(data["name"])  # type-safe key access

create_user({"name": "Alice", "email": "alice@example.com"})

ParamSpec for decorator typing

from typing import TypeVar, Callable, ParamSpec
import functools

P = ParamSpec("P")
R = TypeVar("R")

def log_calls(func: Callable[P, R]) -> Callable[P, R]:
    @functools.wraps(func)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

@log_calls
def add(a: int, b: int) -> int:
    return a + b

# Type checker knows add(1, 2) -> int

TypeGuard for type narrowing

from typing import TypeGuard

def is_string_list(val: list[object]) -> TypeGuard[list[str]]:
    return all(isinstance(item, str) for item in val)

def process(items: list[object]) -> None:
    if is_string_list(items):
        # Type checker knows items is list[str] here
        print(", ".join(items))

Overloaded function signatures

from typing import overload

@overload
def parse(raw: str) -> dict[str, str]: ...
@overload
def parse(raw: bytes) -> dict[str, bytes]: ...

def parse(raw: str | bytes) -> dict[str, str] | dict[str, bytes]:
    if isinstance(raw, str):
        return {"data": raw}
    return {b"data": raw}

Type aliases

from typing import TypeAlias

JSON: TypeAlias = dict[str, "JSON"] | list["JSON"] | str | int | float | bool | None
Callback: TypeAlias = Callable[[int, str], None]
Matrix: TypeAlias = list[list[float]]

Best Practices

  • Use modern syntax (X | Y instead of Union[X, Y], list[int] instead of List[int]) when targeting Python 3.10+.
  • Use from __future__ import annotations on Python 3.9 to enable modern syntax via PEP 563.
  • Run mypy in CI with --strict or at least --warn-return-any --disallow-untyped-defs.
  • Use Protocol over ABC when you want structural ("duck") typing without forcing inheritance.
  • Annotate public APIs fully; internal helper functions benefit too but are lower priority.
  • Use Final for constants and ClassVar for class-level attributes to distinguish from instance attributes.

Common Pitfalls

  • Using Any as an escape hatch silences the type checker entirely for that value — use object when you mean "any type but still type-safe."
  • Mutable default in annotationsdef f(x: list[int] = []) is a runtime bug unrelated to types; type checkers do not catch it.
  • Confusing type[X] with Xtype[User] means the class itself, User means an instance.
  • Forgetting variance — a list[Dog] is not a list[Animal] because lists are mutable (invariant); use Sequence[Animal] for covariant read-only access.
  • Circular imports for type hints — use from __future__ import annotations or TYPE_CHECKING guard to break cycles.

Anti-Patterns

  • Any as the default — using Any whenever the type is not immediately obvious instead of taking the time to determine the correct type. Any silences the type checker entirely for that value, defeating the purpose of type annotations. Use object when you mean "any type but still type-safe."

  • Type hints without a type checker — adding annotations to your codebase but never running mypy, pyright, or an equivalent tool in CI. Unenforced annotations rot quickly as the code evolves, becoming misleading documentation that claims one type while the runtime uses another.

  • Overly complex generic signatures — writing function signatures like Callable[[Mapping[str, Sequence[tuple[int, ...]]], ...], Awaitable[list[dict[str, Any]]]] that are harder to read than the code itself. If a type annotation needs a paragraph to understand, simplify the API or use a TypeAlias to break it into named components.

  • Ignoring variance in container types — accepting list[Animal] when you only read from the collection, forcing callers to widen their specific list[Dog]. Use Sequence[Animal] (covariant, read-only) for parameters that only iterate or index, and reserve list for when you genuinely need to append or modify.

  • Lying annotations — annotating a function as -> str when it can also return None in certain branches, or declaring a parameter as int when the function also accepts strings. Incorrect annotations are worse than no annotations because they build false confidence and cause type-checker-verified code to crash at runtime.

Install this skill directly: skilldb add python-patterns-skills

Get CLI access →