Python Type Hints: From Basics to Production-Grade Annotations

Requires
Python 3.10+
Difficulty
Intermediate
Published
Updated
Author
type hints mypy typing module generics static analysis TypedDict Protocol Union types Literal type aliases

Why type hints matter

Python is dynamically typed — a variable can hold any value at any time, and the interpreter never checks what type you intended. For a 50-line script that runs once, this is a feature. For a 50,000-line codebase with five developers, it becomes the primary source of runtime bugs: passing a string where an integer was expected, returning None from a function that callers assume always returns a list, or misremembering the shape of a dict that got passed three function calls deep.

Type hints, introduced in PEP 484 (Python 3.5) and expanded with every release since, address this by letting you annotate exactly what types a function accepts and returns. The annotations don't change how Python runs your code — they're metadata. What they unlock is static analysis: running a tool like mypy before deployment to catch type errors the same way a compiler catches them in Go or Java.

Here is a realistic before-and-after. The untyped version has a bug that is invisible until production:

Python — before
# No type hints — the bug is invisible
def get_user_age(user):
    return user["age"]

def is_adult(user):
    return get_user_age(user) >= 18

# Caller passes age as a string — fails at runtime with TypeError
user = {"name": "Alice", "age": "30"}  # age is "30", not 30
print(is_adult(user))  # TypeError: '>=' not supported between 'str' and 'int'
Python — after
# With type hints — mypy catches the bug before the code ever runs
def get_user_age(user: dict[str, int]) -> int:
    return user["age"]

def is_adult(user: dict[str, int]) -> bool:
    return get_user_age(user) >= 18

# mypy output: error: Dict entry "age": expected "int", got "str"
user = {"name": "Alice", "age": "30"}

The annotation doesn't fix the bug — it makes it impossible to miss. This is the core value proposition: shift errors from runtime surprises to static analysis findings that your CI pipeline catches on every push.

Variables and function annotations

The syntax for annotating a variable is a colon after the name, followed by the type. For functions, parameters get annotated inline and the return type follows the closing parenthesis with ->:

Python
# Variable annotations
name:  str  = "Alice"
age:   int  = 30
score: float = 9.5
active: bool = True

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

def add(x: int, y: int) -> int:
    return x + y

# Functions that return nothing use -> None
def log_message(msg: str) -> None:
    print(f"[LOG] {msg}")

# Default parameter values come after the annotation
def repeat(text: str, times: int = 1) -> str:
    return text * times

Beyond the four scalar types (str, int, float, bool), you will regularly annotate collections. Before Python 3.9, you had to import container types from the typing module. From 3.9 onward, the built-in types accept subscript syntax directly:

Python
# Pre-3.9: had to import from typing
from typing import List, Dict, Tuple, Set

def old_style(names: List[str]) -> Dict[str, int]:
    return {name: len(name) for name in names}

# Python 3.9+: use built-in types directly
def new_style(names: list[str]) -> dict[str, int]:
    return {name: len(name) for name in names}

# Tuples: specify the type of each element
def get_coordinates() -> tuple[float, float]:
    return (51.5074, -0.1278)

# Tuple of variable length (all same type): tuple[int, ...]
def sum_all(values: tuple[int, ...]) -> int:
    return sum(values)

# Sets and frozensets
def unique_words(text: str) -> set[str]:
    return set(text.lower().split())
If you need Python 3.8 or 3.9 compatibility but want to use the modern lowercase syntax, add from __future__ import annotations at the top of the file. This defers all annotation evaluation and allows the newer syntax on older interpreters.

Annotating class attributes and methods

Python
class User:
    # Class-level annotation without value = instance attribute declaration
    name:  str
    email: str
    age:   int

    def __init__(self, name: str, email: str, age: int) -> None:
        self.name  = name
        self.email = email
        self.age   = age

    def is_adult(self) -> bool:
        return self.age >= 18

    # Class methods annotate 'cls' as type[User]
    @classmethod
    def from_dict(cls: "type[User]", data: dict[str, str | int]) -> "User":
        return cls(
            name  = str(data["name"]),
            email = str(data["email"]),
            age   = int(data["age"]),
        )

Built-in generics (Python 3.9+)

Python 3.9 made every standard collection type generic by default — you no longer need to import capitalised versions from typing. Here is the full picture of what changed and how to use each type correctly:

Python 3.9+
# list[T]
def first(items: list[int]) -> int:
    return items[0]

# dict[KeyType, ValueType]
def count_chars(text: str) -> dict[str, int]:
    counts: dict[str, int] = {}
    for ch in text:
        counts[ch] = counts.get(ch, 0) + 1
    return counts

# tuple[T1, T2] — fixed-length, each element typed separately
Point = tuple[float, float]

def distance(a: Point, b: Point) -> float:
    import math
    return math.hypot(b[0] - a[0], b[1] - a[1])

# set[T]
def intersection(a: set[str], b: set[str]) -> set[str]:
    return a & b

# Nested generics: list of dicts
def get_names(records: list[dict[str, str]]) -> list[str]:
    return [r["name"] for r in records]

The collections.abc module provides abstract container types that are more flexible than the concrete ones. Using these for function parameters is better practice because it accepts any iterable, not just lists:

Python 3.9+
from collections.abc import Sequence, Iterable, Callable, Generator

# Sequence: any ordered type with len() and indexing (list, tuple, str)
def last(items: Sequence[int]) -> int:
    return items[-1]

# Iterable: anything you can iterate — most permissive
def total(values: Iterable[int]) -> int:
    return sum(values)

# Callable[[arg_types], return_type]
def apply_twice(fn: Callable[[int], int], x: int) -> int:
    return fn(fn(x))

print(apply_twice(lambda n: n * 2, 3))  # 12

# Generator[YieldType, SendType, ReturnType]
def countdown(n: int) -> Generator[int, None, str]:
    while n > 0:
        yield n
        n -= 1
    return "done"
Prefer Sequence over list for function parameters when you don't mutate the input — it makes the function work with tuples, strings, and any other ordered sequence, not just lists. Reserve list[T] for parameters you actually modify or return.

Union and Optional

Sometimes a value can legitimately be one of several types. Before Python 3.10, Union from the typing module handled this. Python 3.10 introduced the | operator, which is now the preferred syntax:

Python
from typing import Union  # only needed pre-3.10

# Pre-3.10 syntax
def old_parse(value: Union[str, int, None]) -> str:
    return str(value)

# Python 3.10+ syntax — cleaner and identical in meaning
def new_parse(value: str | int | None) -> str:
    return str(value)

# Real example: parsing a config value that might be an int or a string
def get_port(config: dict[str, str | int]) -> int:
    raw = config["port"]
    if isinstance(raw, str):
        return int(raw)
    return raw  # mypy knows raw is int here — narrowing

The most common union is T | None — a value that might be missing. This was previously spelled Optional[T], which is still valid but now considered the legacy form:

Python
from typing import Optional

# These three are all equivalent
def a(x: Optional[str]) -> None: ...     # legacy typing.Optional
def b(x: str | None) -> None: ...          # preferred Python 3.10+
def c(x: Union[str, None]) -> None: ...     # verbose Union form

# Always narrow before using an Optional value
def greet(name: str | None) -> str:
    if name is None:
        return "Hello, stranger!"
    return f"Hello, {name}!"
    # After the if check, mypy knows name is str — no need to cast

# Type narrowing with isinstance also works
def double(x: int | str) -> int | str:
    if isinstance(x, int):
        return x * 2        # mypy: x is int here
    return x + x           # mypy: x is str here

Type narrowing — where mypy infers a more specific type after a conditional check — is one of the most powerful features of the type system. After if name is None: return, mypy knows that on any code path that continues, name must be a str. No cast needed.

Type aliases

When the same complex type appears repeatedly, name it. Type aliases make signatures readable and create a single point of change if the type evolves:

Python 3.12+
from typing import TypeAlias

# Without alias: hard to read
def process(data: dict[str, list[dict[str, str | int]]]) -> list[str]:
    ...

# With alias: clear intent
Row:       TypeAlias = dict[str, str | int]
DataTable: TypeAlias = dict[str, list[Row]]

def process(data: DataTable) -> list[str]:
    ...

# Python 3.12 introduced 'type' statement (even cleaner)
type UserId = int
type UserMap = dict[UserId, str]
Python 3.9+ (pre-3.12 compatible)
# Simple assignment works for simple aliases in 3.9+
Vector = list[float]
Matrix = list[Vector]

def dot_product(v1: Vector, v2: Vector) -> float:
    return sum(a * b for a, b in zip(v1, v2))

def transpose(m: Matrix) -> Matrix:
    return [list(row) for row in zip(*m)]

# Domain-specific alias makes argument intent obvious
Url      = str
HtmlBody = str

def fetch_page(url: Url) -> HtmlBody:
    ...

TypedDict for structured dicts

Plain dict[str, Any] abandons type safety entirely — you lose all checking on the values. TypedDict solves this by letting you define the exact expected keys and their individual types for a dictionary structure. It is ideal for JSON responses, config objects, and any dict with a fixed schema.

Python
from typing import TypedDict

# Define the schema for a user record
class User(TypedDict):
    id:    int
    name:  str
    email: str

def get_display_name(user: User) -> str:
    return user["name"]

# mypy catches this: 'age' is not a valid key for User
alice: User = {"id": 1, "name": "Alice", "email": "alice@example.com"}
wrong: User = {"id": 2, "name": "Bob", "age": 25}  # error: extra key 'age'

TypedDict supports optional keys using Required and NotRequired (Python 3.11+), or via inheritance to split required and optional groups:

Python 3.11+
from typing import TypedDict, NotRequired, Required

class Product(TypedDict):
    id:          int            # required
    name:        str            # required
    description: NotRequired[str]  # optional — may be absent
    discount:    NotRequired[float] # optional

def render_product(p: Product) -> str:
    desc = p.get("description", "No description available")
    return f"{p['name']}: {desc}"

# Valid even without optional keys
item: Product = {"id": 1, "name": "Keyboard"}
Python — inheritance pattern (3.9+ compatible)
# Alternative for pre-3.11: split required and optional via inheritance
class _ProductRequired(TypedDict):
    id:   int
    name: str

class Product(_ProductRequired, total=False):
    description: str    # total=False makes these optional
    discount:    float

TypedDict is particularly useful when working with APIs. Instead of annotating the return type of a JSON-parsing function as dict[str, Any] and losing all type information downstream, you define a TypedDict for the expected response shape, giving mypy visibility into every field access.

Protocol — structural subtyping

In traditional object-oriented typing, a class satisfies a type constraint only if it explicitly inherits from that type. Protocol takes a different approach: a class satisfies a Protocol if it has the required attributes and methods, regardless of its inheritance chain. This is called structural subtyping, or "static duck typing."

Python
from typing import Protocol

# Define what "Drawable" means — any object with a draw() method
class Drawable(Protocol):
    def draw(self) -> None: ...

# These classes never heard of Drawable — they just happen to have draw()
class Circle:
    def draw(self) -> None:
        print("Drawing a circle")

class Rectangle:
    def draw(self) -> None:
        print("Drawing a rectangle")

class Button:
    def click(self) -> None:  # no draw() method
        print("Button clicked")

# This function accepts any Drawable — mypy checks structurally
def render_all(shapes: list[Drawable]) -> None:
    for shape in shapes:
        shape.draw()

render_all([Circle(), Rectangle()])   # OK — both have draw()
render_all([Button()])                  # error: Button has no draw()

This is powerful for library design because callers never need to import your Protocol or modify their classes — they just need to provide the right interface:

Python
from typing import Protocol, runtime_checkable

# runtime_checkable makes isinstance() work with the Protocol
@runtime_checkable
class Closeable(Protocol):
    def close(self) -> None: ...

# Protocol with attributes and methods
class Serializable(Protocol):
    id:   int
    name: str

    def to_dict(self) -> dict[str, str | int]: ...

def serialize_batch(items: list[Serializable]) -> list[dict[str, str | int]]:
    return [item.to_dict() for item in items]

# Any class with id, name, and to_dict() qualifies — no imports required
class Product:
    def __init__(self, id: int, name: str):
        self.id   = id
        self.name = name

    def to_dict(self) -> dict[str, str | int]:
        return {"id": self.id, "name": self.name}

serialize_batch([Product(1, "Keyboard")])  # mypy: OK

Literal and Final

Literal restricts a type to a specific set of allowed values, not just a type. This is ideal for function parameters that only make sense with particular strings or integers:

Python
from typing import Literal

# Only "asc" or "desc" are valid directions
Direction = Literal["asc", "desc"]

def sort_users(users: list[str], direction: Direction) -> list[str]:
    return sorted(users, reverse=(direction == "desc"))

sort_users(["Bob", "Alice"], "asc")    # OK
sort_users(["Bob", "Alice"], "up")     # error: "up" is not Literal["asc", "desc"]

# HTTP methods
HttpMethod = Literal["GET", "POST", "PUT", "DELETE", "PATCH"]

def make_request(method: HttpMethod, url: str) -> None:
    print(f"{method} {url}")

# Integer literals work too
LogLevel = Literal[1, 2, 3]  # DEBUG=1, INFO=2, ERROR=3

Final marks a variable or attribute as a constant — mypy will raise an error if any code tries to reassign it:

Python
from typing import Final

MAX_RETRIES: Final = 3
API_BASE:    Final[str] = "https://api.example.com/v1"
TIMEOUT:     Final[float] = 30.0

# mypy error: Cannot assign to final name "MAX_RETRIES"
MAX_RETRIES = 5

# Final in a class means the method cannot be overridden in subclasses
from typing import final

class Base:
    @final
    def compute_id(self) -> str:
        return str(id(self))

class Child(Base):
    def compute_id(self) -> str:  # error: Cannot override final method
        return "custom"

Overload for multiple signatures

Some functions have different return types depending on what arguments you pass. The @overload decorator lets you declare multiple signatures for a single function so mypy can infer the correct return type at each call site:

Python
from typing import overload

# Without @overload, mypy cannot infer that int input → int output
# and str input → str output. It can only say: returns int | str.

@overload
def double(x: int) -> int: ...
@overload
def double(x: str) -> str: ...

# The actual implementation (not type-checked at call sites)
def double(x: int | str) -> int | str:
    if isinstance(x, int):
        return x * 2
    return x + x

# mypy now knows the types precisely
result_int: int = double(5)        # OK — returns int
result_str: str = double("hi")      # OK — returns str
result_bad: int = double("hi")      # error: "str" is not "int"
The overload signatures are type-checking hints only — they never execute. Always write the real implementation without the @overload decorator, with a union signature broad enough to cover all overloads.

Generics with TypeVar

A generic function works with a family of types while preserving the relationship between inputs and outputs. TypeVar is the tool for expressing "whatever type you pass in, I return the same type":

Python
from typing import TypeVar

T = TypeVar("T")

# Without TypeVar: mypy cannot tell what type first() returns
def first_untyped(items: list) -> ...:   # would be 'Any' — useless
    return items[0]

# With TypeVar: mypy knows if you pass list[str], you get str back
def first(items: list[T]) -> T:
    return items[0]

name: str = first(["Alice", "Bob"])  # OK — T bound to str
num:  int = first([1, 2, 3])          # OK — T bound to int
bad:  int = first(["Alice", "Bob"])   # error: got str, expected int

Use bound to constrain the TypeVar to a type or its subclasses, or constraints to limit it to an explicit set:

Python
from typing import TypeVar
from collections.abc import Comparable

# bound: T must be a subclass of the bound type
Comparable = TypeVar("Comparable", bound=float)

def clamp(value: Comparable, lo: Comparable, hi: Comparable) -> Comparable:
    return max(lo, min(value, hi))

# constraints: T must be exactly one of the listed types
AnyStr = TypeVar("AnyStr", str, bytes)

def encode_if_str(data: AnyStr) -> AnyStr:
    if isinstance(data, str):
        return data.upper()  # type: ignore[return-value]
    return data

# Python 3.12 introduces cleaner syntax with type parameters
# (no TypeVar import needed)
def last[T](items: list[T]) -> T:  # Python 3.12+ only
    return items[-1]

Generic classes

Python
from typing import TypeVar, Generic

T = TypeVar("T")

class Stack(Generic[T]):
    def __init__(self) -> None:
        self._items: list[T] = []

    def push(self, item: T) -> None:
        self._items.append(item)

    def pop(self) -> T:
        return self._items.pop()

    def peek(self) -> T | None:
        return self._items[-1] if self._items else None

# mypy infers the specific type for each Stack instance
int_stack: Stack[int] = Stack()
int_stack.push(42)
int_stack.push("oops")   # error: expected int, got str

str_stack: Stack[str] = Stack()
str_stack.push("hello")   # OK

Running mypy in CI

Type hints have no value if you never actually check them. The point is to run mypy as part of your CI pipeline so that every pull request gets type-checked before it can merge.

Installation and basic usage

Shell
# Install mypy
pip install mypy

# Type-check a single file
mypy mymodule.py

# Type-check an entire package
mypy src/

# Strict mode: enables all optional checks
mypy --strict src/

# Check specific flags
mypy --disallow-untyped-defs --disallow-any-generics src/

mypy configuration (mypy.ini or pyproject.toml)

pyproject.toml
[tool.mypy]
python_version = "3.11"
strict = true                    # enables all checks below
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true     # all functions must be annotated
disallow_any_generics = true     # no bare list, dict — must be list[str] etc.
check_untyped_defs = true
no_implicit_reexport = true

# Per-module overrides for third-party code without stubs
[[tool.mypy.overrides]]
module = ["requests.*", "boto3.*"]
ignore_missing_imports = true

GitHub Actions integration

YAML — .github/workflows/typecheck.yml
name: Type Check

on: [push, pull_request]

jobs:
  mypy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"
          cache: pip

      - name: Install dependencies
        run: |
          pip install -e ".[dev]"
          pip install mypy

      - name: Run mypy
        run: mypy src/ --strict
Start with a subset of your codebase. Running mypy on legacy code all at once is overwhelming. Add a [[tool.mypy.overrides]] block to ignore modules you haven't typed yet, then progressively tighten the scope as you annotate them.

Common mypy errors and fixes

Here are the errors you will see most often, what they mean, and how to resolve them properly:

Python
# Error: Item "None" of "str | None" has no attribute "upper"
def bad(name: str | None) -> str:
    return name.upper()  # error! name might be None

# Fix: narrow the type first
def good(name: str | None) -> str:
    if name is None:
        return ""
    return name.upper()  # mypy now knows name is str

# ─────────────────────────────────────────────

# Error: Incompatible return value type (got "int", expected "str")
def bad_return(x: int) -> str:
    return x       # error: x is int, not str

# Fix: convert explicitly
def good_return(x: int) -> str:
    return str(x)

# ─────────────────────────────────────────────

# Error: Argument 1 has incompatible type "str"; expected "int"
def add(x: int, y: int) -> int:
    return x + y

user_input = input("Enter number: ")  # input() returns str
add(1, user_input)  # error: user_input is str

# Fix: parse before passing
add(1, int(user_input))

# ─────────────────────────────────────────────

# Error: Need type annotation for "items" (hint: "items: list[<type>] = []")
items = []      # mypy cannot infer the element type of an empty list

# Fix: annotate the variable explicitly
items: list[str] = []

# ─────────────────────────────────────────────

# Using cast() as a last resort (avoid if possible)
from typing import cast

raw: object = get_something()
typed = cast(str, raw)   # tells mypy "trust me, raw is str"
# cast() has ZERO runtime effect — it is a pure type annotation
# Use only when you know more than mypy does and cannot narrow normally

One final pattern worth knowing: when you cannot avoid Any (e.g., for dynamic data from external sources), confine it to the boundary. Parse the external input into typed structures as early as possible, and let mypy's type narrowing take over from there. The goal is not to eliminate Any everywhere — it is to prevent it from spreading beyond the I/O layer.

Frequently Asked Questions

Do Python type hints affect runtime performance?
No. Python type hints are completely ignored by the CPython interpreter at runtime. They exist solely for static analysis tools like mypy and pyright, and for IDE autocompletion. Adding type hints to a Python program does not slow it down by any measurable amount. The annotations are stored as metadata in __annotations__ but never evaluated during normal execution.
What is the difference between Optional[str] and str | None?
They are equivalent. Optional[str] from the typing module was the pre-3.10 way to express "str or None". Python 3.10 introduced the | union operator for types, making str | None the modern preferred form. Both mypy and pyright accept both syntaxes. For codebases that target Python 3.9 or earlier, use Optional[str] or the from __future__ import annotations workaround.
When should I use Protocol instead of ABC?
Use Protocol when you want structural subtyping — any class that has the required methods satisfies the Protocol, without needing to explicitly inherit from it. This is called duck-typing made static. Use ABC (Abstract Base Class) when you want nominal subtyping and want to enforce inheritance explicitly. Protocol is generally preferred in modern Python for library APIs because callers do not need to import or inherit from your type.
Can I use type hints with Python 3.8 or 3.9?
Yes. Add "from __future__ import annotations" at the top of every file. This makes all annotations strings evaluated lazily, which means you can use Python 3.10+ syntax like "str | None" and "list[int]" even on older Python versions. The only cost is that runtime access to annotations (via typing.get_type_hints()) requires extra care.