Python is dynamically typed, but since Python 3.5 you can annotate variables, function parameters, and return values with type hints. Combined with static analysis tools, this catches bugs before runtime.

Basic Type Annotations

  def greet(name: str) -> str:
    return f"Hello, {name}!"

age: int = 25
scores: list[float] = [95.5, 87.0, 91.3]
  

Type hints are optional at runtime — Python ignores them during execution. They serve documentation and static analysis tools.

Common Built-in Types

  from typing import Optional, Union

def find_user(user_id: int) -> Optional[str]:
    """Returns username or None if not found."""
    ...

def parse(value: Union[int, str]) -> int:
    """Accepts int or str, always returns int."""
    return int(value)
  
Annotation Meaning
str, int, float, bool Primitive types
list[str] List of strings (Python 3.9+)
dict[str, int] Dict with string keys, int values
tuple[int, str] Fixed-length tuple
Optional[T] T or None
Union[A, B] Either type A or B

Generic Collections

For Python 3.8 and earlier, import from typing:

  from typing import List, Dict, Tuple, Set

def average(numbers: List[float]) -> float:
    return sum(numbers) / len(numbers)

cache: Dict[str, int] = {}
  

Python 3.9+ allows built-in generics directly: list[float], dict[str, int].

TypedDict and NamedTuple

  from typing import TypedDict

class User(TypedDict):
    name: str
    age: int
    email: str

def create_user(data: User) -> User:
    return data

user: User = {"name": "Alice", "age": 30, "email": "[email protected]"}
  

Protocol (Structural Subtyping)

Protocol defines an interface without inheritance:

  from typing import Protocol

class Drawable(Protocol):
    def draw(self) -> None: ...

def render(shape: Drawable) -> None:
    shape.draw()

class Circle:
    def draw(self) -> None:
        print("Drawing circle")

render(Circle())  # Circle satisfies Drawable structurally
  

Callable Types

  from typing import Callable

def apply(func: Callable[[int, int], int], a: int, b: int) -> int:
    return func(a, b)

apply(lambda x, y: x + y, 3, 5)  # → 8
  

Static Analysis with mypy

Install and run mypy to check types across your codebase:

  pip install mypy
mypy your_module.py
  

Example — mypy catches this error before runtime:

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

add("hello", 5)  # mypy error: Argument 1 has incompatible type "str"
  

Configuration

Create mypy.ini or add to pyproject.toml:

  [tool.mypy]
python_version = "3.11"
strict = true
ignore_missing_imports = true
  

When to Use Type Hints

  • Public APIs and libraries — always annotate function signatures.
  • Large codebases — types pay off as teams and code grow.
  • Data processing pipelines — catch shape mismatches early.
  • Skip for quick scripts, notebooks, and throwaway prototypes.

Type hints don’t replace tests, but they complement them by catching an entire class of errors at development time.

Modern typing Features (Python 3.10+)

Union with |

  def parse_id(value: int | str) -> int:
    return int(value)

def find_user(user_id: int) -> str | None:
    ...
  

Prefer X | Y over Union[X, Y] in new code.

TypeAlias and Literal

  from typing import Literal, TypeAlias

Status = Literal["pending", "active", "archived"]
UserId: TypeAlias = int | str

def set_status(status: Status) -> None:
    ...

set_status("active")   # OK
set_status("unknown")  # mypy error
  

Self — Fluent Interfaces

  from typing import Self

class Builder:
    def reset(self) -> Self:
        return self

    def add(self, item: str) -> Self:
        self.items.append(item)
        return self
  

Typed Classes with dataclasses

Combine @dataclass with type hints for clear data models:

  from dataclasses import dataclass

@dataclass
class Product:
    name: str
    price: float
    in_stock: bool = True

def apply_discount(product: Product, pct: float) -> Product:
    return Product(product.name, product.price * (1 - pct), product.in_stock)
  

See Advanced Topics for dataclass details.

Runtime Type Checking with @beartype

Optional runtime enforcement (mypy is static only):

  pip install beartype
  
  from beartype import beartype

@beartype
def divide(a: int, b: int) -> float:
    return a / b

divide(10, 2)    # OK
divide("10", 2)  # BeartypeError at runtime
  

Use sparingly — adds overhead. Prefer mypy in CI for most projects.

pyright and IDE Integration

VS Code with Pylance uses pyright for inline type errors:

  // .vscode/settings.json
{
  "python.analysis.typeCheckingMode": "basic"
}
  

Levels: offbasicstrict. Start with basic and increase as coverage improves.

Gradual Typing Strategy

For existing codebases, don’t annotate everything at once:

  1. Enable mypy on new files only (# type: ignore on legacy modules temporarily)
  2. Annotate public function signatures first
  3. Add strict = true module by module
  4. Run mypy in CI — block merges on type errors
  mypy src/new_module.py --strict
mypy src/  # expand scope over time
  
  • FastAPI — Pydantic models extend type hints for API validation
  • Advanced Testing — run mypy alongside pytest in CI