---
title: "Python: Type Hints"
slug: "python-typing"
category: "coding"
tags: ["coding", "python", "typing", "types", "generics", "pep484"]
status: "stable"
last_updated: 2026-05-14
summary: "PEP 484/612/646 type hints in Python 3.12+: TypeVar, Protocol, Self, TypeAlias, Annotated, TypeGuard, and runtime introspection."
related: ["[[coding/python]]", "[[coding/typescript]]", "[[coding/general-principles]]", "[[coding/python-testing]]", "[[backend/fastapi]]"]
---

## Overview

Type hints in Python 3.12+ are not optional. They are the contract between caller and callee, the input to pyright strict mode, and the runtime source for Pydantic validators and FastAPI schemas. Read [[coding/python]] for the project-level setup; this page covers the type system itself.

## Annotate every public function boundary

Every parameter and return type on a public or cross-module function must have an annotation. Internal helpers can rely on pyright's local inference, but the moment a function crosses a module boundary, annotate explicitly.

```python
from collections.abc import Sequence

def paginate(items: Sequence[str], page: int, size: int) -> list[str]:
    start = page * size
    return list(items[start : start + size])
```

Use `collections.abc` types (`Sequence`, `Mapping`, `Callable`, `Iterator`) for parameters. Concrete types (`list`, `dict`) are fine for return types when the caller needs the concrete API.

## Prefer `Protocol` over `ABC` for structural typing

`Protocol` lets you define a structural interface without inheritance. Any class that implements the required methods satisfies the protocol, even third-party classes you cannot modify.

```python
from typing import Protocol, runtime_checkable

@runtime_checkable
class Serializable(Protocol):
    def to_json(self) -> str: ...

def save(obj: Serializable) -> None:
    with open("out.json", "w") as f:
        f.write(obj.to_json())
```

Use `ABC` only when you need shared implementation via `super()`. For interface-only contracts, `Protocol` is strictly better because callers avoid the inheritance dependency.

## Use `TypeVar` and `ParamSpec` for generic functions

`TypeVar` creates a type parameter that the checker can track across a call. `ParamSpec` (PEP 612) captures the full signature of a callable so decorator types stay accurate.

```python
from typing import TypeVar
from collections.abc import Callable

T = TypeVar("T")

def first(items: list[T]) -> T | None:
    return items[0] if items else None


from typing import ParamSpec
import functools

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

def logged(fn: Callable[P, R]) -> Callable[P, R]:
    @functools.wraps(fn)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        print(f"calling {fn.__name__}")
        return fn(*args, **kwargs)
    return wrapper
```

In Python 3.12+, prefer the new `type` statement syntax for simple aliases and the `[T]` syntax on class definitions instead of explicit `TypeVar` declarations.

## Name type aliases with `type` or `TypeAlias`

A bare assignment like `UserId = int` looks like a variable, not a type alias. Use `type UserId = int` (Python 3.12+) or `TypeAlias` (3.10+) to make the intent explicit and give pyright the context to check it correctly.

```python
# Python 3.12+
type UserId = int
type Row = tuple[str, int, float]

# Python 3.10-3.11 fallback
from typing import TypeAlias
UserId: TypeAlias = int
```

## Use `Annotated` for metadata-carrying types

`Annotated[T, metadata]` attaches validation, serialization, or documentation metadata to a type without changing its Python identity. Pydantic v2 uses `Annotated` for field constraints.

```python
from typing import Annotated
from pydantic import Field, BaseModel

PositiveInt = Annotated[int, Field(gt=0)]
ShortStr = Annotated[str, Field(max_length=100)]

class Invoice(BaseModel):
    id: PositiveInt
    description: ShortStr
```

Centralizing constraints in `Annotated` aliases means one change propagates everywhere the alias is used. See [[backend/fastapi]] for how FastAPI reads these annotations for automatic schema generation.

## Narrow types with `TypeGuard` and `TypeIs`

`TypeGuard[T]` (PEP 647) marks a predicate function as a type narrowing guard. `TypeIs[T]` (PEP 742, Python 3.13+) is the stricter form that also narrows the `False` branch.

```python
from typing import TypeGuard

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

def process(items: list[object]) -> None:
    if is_str_list(items):
        # items is list[str] here
        print(", ".join(items))
```

Prefer `TypeGuard` over `cast()`. A guard expresses the check in code; a cast silences the checker without verifying anything. Reserve `cast()` for cases where you have proof the checker cannot see.

## Introspect annotations at runtime with `get_type_hints`

`typing.get_type_hints(cls)` resolves forward references and returns the evaluated annotations dictionary. Use it when you need the type information at runtime, for example in a custom serialization layer.

```python
import typing

class Config:
    host: str
    port: int
    debug: bool = False

hints = typing.get_type_hints(Config)
# {"host": <class 'str'>, "port": <class 'int'>, "debug": <class 'bool'>}
```

Avoid reading `__annotations__` directly; it does not resolve string annotations. Use `get_type_hints` instead.

## Use `Self` for methods that return the same type

`Self` (PEP 673, Python 3.11+) annotates methods that return an instance of the class, including subclasses. It replaces the fragile `TypeVar("T", bound="MyClass")` pattern.

```python
from typing import Self

class Builder:
    def set_name(self, name: str) -> Self:
        self._name = name
        return self

class AdvancedBuilder(Builder):
    pass

# AdvancedBuilder().set_name("x") returns AdvancedBuilder, not Builder
```

## Related

- [[coding/python]]
- [[coding/typescript]]
- [[coding/general-principles]]
- [[coding/python-testing]]
- [[backend/fastapi]]
