Use Type Hints with the Python `typing` Module

Learn how to use Python's typing module to add type hints for lists, dictionaries, unions, optionals, and generics.

7 min read

The typing module in the Python standard library provides the building blocks for type hints: annotations that describe what types of values functions expect and return. Type hints are ignored at runtime. Static type checkers like mypy read them instead, and IDEs use them for autocompletion and error detection.

pythonpython
def greet(name: str, count: int = 1) -> str:
    return f"Hello, {name}! " * count
 
print(greet("Maya", 3))   # Hello, Maya! Hello, Maya! Hello, Maya!

name: str says the first argument must be a string. count: int = 1 says the second argument is an integer with a default of 1. -> str says the function returns a string.

These annotations are stored but not enforced by Python itself.

Basic type annotations

Python's built-in types can be used directly as annotations, without importing anything from typing. This covers bool, int, float, str, bytes, and every other built-in scalar type.

pythonpython
def process(active: bool, count: int, ratio: float, label: str) -> bytes:
    return f"{label}: {count * ratio}".encode()
 
result: bytes = process(True, 10, 1.5, "total")
print(type(result))   # <class 'bytes'>

You can also annotate variables: result: bytes = .... This is useful for complex expressions where the type is not obvious from the initial value.

Collection types

Use the built-in generic syntax (Python 3.9+) for lists, dicts, tuples, and sets.

pythonpython
def get_scores() -> list[int]:
    return [92, 85, 78]
 
def lookup(ids: list[int]) -> dict[int, str]:
    return {1: "Maya", 2: "Devin"}
 
def coordinates() -> tuple[float, float]:
    return 40.7128, -74.0060
 
def unique(items: list[str]) -> set[str]:
    return set(items)
AnnotationMeaning
list[int]List of integers
dict[str, int]Dict with string keys and integer values
tuple[int, str, bool]Tuple with three fixed-type elements
tuple[int, ...]Tuple of any length, all integers
set[float]Set of floats

For Python 3.8 and earlier, import these from typing: List[int], Dict[str, int], Tuple[int, str].

Optional and Union

When a value can be None or one of several types, use Optional and Union, or the | operator (Python 3.10+).

pythonpython
def find_user(user_id: int) -> str | None:
    users = {1: "Maya", 2: "Devin"}
    return users.get(user_id)
 
def parse(value: int | str) -> int:
    if isinstance(value, str):
        return int(value)
    return value
 
print(find_user(1))    # Maya
print(find_user(99))  # None

str | None means the function returns a string or None. int | str means the argument accepts either type. These are equivalent to Optional[str] and Union[int, str] from earlier Python versions.

Callable: annotating function parameters

Use Callable to annotate parameters that expect a function.

pythonpython
from typing import Callable
 
def apply(func: Callable[[int, int], int], a: int, b: int) -> int:
    return func(a, b)
 
def add(x: int, y: int) -> int:
    return x + y
 
print(apply(add, 3, 5))   # 8

Callable[[int, int], int] describes a function that takes two ints and returns an int. The first list contains the argument types; the last element is the return type.

TypedDict: typed dictionaries

TypedDict defines a dictionary with a specific key structure, useful for JSON-like data.

pythonpython
from typing import TypedDict
 
class User(TypedDict):
    name: str
    age: int
    email: str | None
 
def greet(user: User) -> str:
    return f"Hello, {user['name']}!"
 
u: User = {"name": "Maya", "age": 28, "email": None}
print(greet(u))   # Hello, Maya!

Type checkers validate that the dictionary has the correct keys with the correct types. All keys are required by default. For optional keys, use total=False or mark individual keys with NotRequired.

Naming complex types

Give meaningful names to complex type annotations with the type statement (Python 3.12+).

pythonpython
type Vector = list[float]
type Matrix = list[Vector]
type UserRecord = dict[str, str | int | bool]
 
def dot_product(a: Vector, b: Vector) -> float:
    return sum(x * y for x, y in zip(a, b))

Vector is easier to read and change than repeating list[float] everywhere. For Python 3.9 through 3.11, use typing.TypeAlias instead: Vector: TypeAlias = list[float]. The typing.TypeAlias form still works in newer versions but is considered legacy now that the type statement covers the same need directly in the language syntax.

Generics with TypeVar

Use TypeVar to write functions and classes that work with multiple types while preserving type relationships.

pythonpython
from typing import TypeVar
 
T = TypeVar("T")
 
def first(items: list[T]) -> T | None:
    return items[0] if items else None
 
print(first([1, 2, 3]))    # 1
print(first(["a", "b"]))  # a

first([1, 2, 3]) is inferred as int | None. first(["a", "b"]) is inferred as str | None. The type checker preserves the relationship between the input list type and the return type.

Practical example: typed API client

Use type hints throughout a small module to make the interface clear. Two TypedDicts describe the shapes of the request configuration and the parsed response.

pythonpython
from typing import TypedDict
import json
 
class ApiConfig(TypedDict):
    base_url: str
    timeout: int
    retries: int | None
 
class ApiResponse(TypedDict):
    status: int
    data: dict[str, object] | None
    error: str | None

With both shapes defined, the functions that build a request URL and parse a raw response can reference them directly in their signatures:

pythonpython
def build_url(config: ApiConfig, path: str) -> str:
    return f"{config['base_url']}{path}"
 
def parse_response(raw: str) -> ApiResponse:
    data = json.loads(raw)
    return {
        "status": data.get("status", 200),
        "data": data.get("data"),
        "error": data.get("error"),
    }

Calling build_url with a config dict that matches the ApiConfig shape produces the full request URL, and a type checker would flag it immediately if a required key like base_url were missing:

pythonpython
config: ApiConfig = {"base_url": "https://api.example.com", "timeout": 30, "retries": None}
url = build_url(config, "/users")
print(url)

The base_url and path are joined directly, producing the full endpoint URL for this particular request, exactly as build_url's return type annotation promises:

texttext
https://api.example.com/users

The type hints document the shape of configuration and response objects. A type checker catches mismatched keys, wrong types, and missing fields before the code runs.

Common mistakes

Using typing.List instead of list for annotations. Since Python 3.9, prefer list[int] over typing.List[int]. The built-in generic syntax is shorter and does not require an import.

Thinking type hints are enforced at runtime. Python never enforces type hints. They are documentation and tooling aids. Use mypy or pyright (in VS Code) to check them.

Over-annotating obvious types. x: int = 5 is unnecessary because the type is obvious. Use annotations on function signatures, complex variables, and module boundaries where they add clarity.

Forgetting that None is not included by default. def find(id: int) -> str: says the function always returns a string. If it can return None, write -> str | None. This is one of the most common bugs type checkers catch.

Rune AI

Rune AI

Key Insights

  • Annotate function parameters and return types with def greet(name: str) -> str:.
  • Use list[int], dict[str, int], tuple[int, str] for built-in generics (Python 3.9+).
  • Use Optional[str] or str | None when a value can be None.
  • Use Union[int, str] or int | str for values that accept multiple types.
  • Use Callable[[int, int], int] to annotate function parameters.
  • Use TypedDict for dictionaries with a known key structure.
  • Type hints are optional at runtime; use mypy or pyright to check them.
RunePowered by Rune AI

Frequently Asked Questions

Do type hints affect runtime performance?

No. Python ignores type hints at runtime. They are stored in `__annotations__` for introspection but are never enforced by the interpreter. Type hints are used by static type checkers like mypy, IDE autocompletion, and documentation generators.

What is the difference between Optional[X] and Union[X, None]?

They are equivalent in Python 3.10+. `Optional[X]` is shorthand for `Union[X, None]`. In Python 3.10+, you can also write `X | None` as a cleaner alternative to both.

Should I add type hints to all my code?

Start with public function signatures in new code. Type hints are most valuable at module boundaries where they help callers understand expectations. Adding hints everywhere in existing code is time-consuming; focus on the interfaces first.

Conclusion

Type hints make Python code more readable and help catch bugs before runtime. Use the built-in generics like list[int] and dict[str, int] (Python 3.9+) for simple cases, and the typing module for advanced types like Optional, Union, Callable, and TypedDict. Combine hints with a type checker like mypy for the full benefit.