Skip to content
C Codeloom
Python

Python Type Hints: A Practical Guide

Learn how Python type hints work in real code: annotations, Optional, Union, generics, TypedDict, Protocol, and running mypy or pyright on your project.

·7 min read · By Yash Kesharwani
Intermediate 11 min read

What you'll learn

  • How to annotate variables, function arguments, and return types
  • How to express Optional and Union types with the modern | syntax
  • How to write generic functions and classes
  • How TypedDict and Protocol model dict shapes and duck typing
  • How to run mypy or pyright and gradually adopt typing on a real codebase

Prerequisites

Python is a dynamically typed language. Type hints do not change that. They are notes for humans and tools that say “this argument should be a string” or “this function returns a list of users.” Static checkers like mypy and pyright read those notes and yell at you when the code disagrees. Editors use them for autocomplete and refactoring. The runtime mostly ignores them.

That sounds like a small thing. In practice, type hints catch a surprising number of bugs and make code much easier to read. Here is how to use them without going overboard.

Basic annotations

You annotate parameters and return types in function signatures. You can annotate variables too, though that is rarer.

def greet(name: str) -> str:
    return f"hello {name}"

count: int = 0
names: list[str] = ["ana", "bo"]

A few rules:

  • Annotations do not enforce anything at runtime. greet(42) still runs.
  • Use the built-in container types (list, dict, tuple, set) with subscripts. The old typing.List form still works but is no longer recommended.
  • -> None is the right return annotation for functions that do not return anything explicitly.

Optional and Union

A value that might be None is Optional[X] or, in modern Python, X | None.

def find_user(user_id: int) -> dict | None:
    if user_id == 1:
        return {"id": 1, "name": "ana"}
    return None

Union[int, str] and int | str mean the same thing. The pipe syntax is shorter and works without an import on Python 3.10+. For older versions, import from typing.

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

Avoid Any unless you really mean “I do not want type checking here.” It silently disables checks for anything that touches it.

Generic containers

Subscript the container with the element type.

def total(prices: list[float]) -> float:
    return sum(prices)

def index_by_id(items: list[dict]) -> dict[int, dict]:
    return {item["id"]: item for item in items}

For tuples, you specify the type of each position, or use tuple[X, ...] for a variable-length tuple.

def point() -> tuple[float, float]:
    return (1.0, 2.0)

def all_strings() -> tuple[str, ...]:
    return ("a", "b", "c")

Writing your own generics

Sometimes a function works for any type and you want the type system to know that. Use a type variable.

from typing import TypeVar

T = TypeVar("T")

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

Now first(["a", "b"]) is known to return str, and first([1, 2]) is known to return int. On Python 3.12+, the syntax is even tighter:

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

Generic classes follow the same idea. They are how list[int] is itself written.

class Box[T]:
    def __init__(self, value: T) -> None:
        self.value = value

    def get(self) -> T:
        return self.value

TypedDict for structured dicts

Many Python APIs hand you dicts with known keys. TypedDict lets you describe their shape.

from typing import TypedDict

class UserDict(TypedDict):
    id: int
    name: str
    active: bool

def render(user: UserDict) -> str:
    return f"{user['id']}: {user['name']}"

You still use it like a normal dict at runtime, but mypy will warn if you forget a key, misspell one, or pass an int where the schema expects a str. Mark keys as optional with NotRequired from typing.

If you find yourself reaching for TypedDict often, consider a real class or a dataclass instead. Dicts are a fine messaging format, but classes are usually nicer to work with in your own code.

Protocol for duck typing

Python is famously duck-typed: if it has a read method, treat it like a file. Protocol lets you express that without inheritance.

from typing import Protocol

class Readable(Protocol):
    def read(self) -> str: ...

def load(source: Readable) -> str:
    return source.read()

Any object with a matching read method satisfies Readable, without ever importing or inheriting from it. This is how typing.Iterable, typing.Sized, and friends work. Use it for plugin interfaces, dependency injection, and any place you want to type “things that look like X.”

Callable, Iterable, Iterator

For functions and iteration, the standard hints are:

from collections.abc import Callable, Iterable, Iterator

def apply(fn: Callable[[int], int], values: Iterable[int]) -> Iterator[int]:
    for v in values:
        yield fn(v)

Callable[[int, str], bool] means a function that takes an int and a str and returns a bool. Use these from collections.abc rather than typing on modern Python. For more on iterators and generators, see generators and iterators.

Running a type checker

Annotations are most valuable when something is actually checking them. The two common choices are mypy and pyright.

Install and run mypy:

pip install mypy
mypy src/

Or pyright:

pip install pyright
pyright src/

Both will start by reporting issues. On an existing codebase, do not try to fix them all at once. A reasonable adoption path:

  • Configure the checker to be strict on new files only, lenient on the rest.
  • Add types to the public boundaries of your modules first: function signatures, class attributes.
  • Use # type: ignore[error-code] sparingly for places you know better than the checker.
  • Treat new errors in CI as build failures so the codebase does not regress.

A minimal mypy.ini:

[mypy]
python_version = 3.12
strict = True
ignore_missing_imports = True

strict = True turns on a sensible bundle of checks. ignore_missing_imports = True keeps third-party libraries without type stubs from drowning you in noise.

Common pitfalls

  • Annotating with Any everywhere. That defeats the purpose. If something is genuinely dynamic, fine, but be honest about it.
  • Using Optional[X] and then forgetting to handle None. A type checker catches this immediately.
  • Mixing old List[int] and new list[int] syntax for no reason. Pick one (probably the lowercase one) and stick to it.
  • Treating type hints as runtime validation. They are not. For runtime checks at the edges of your system, use Pydantic or explicit code.
  • Over-typing tiny scripts. A 20-line file with five type variables and three protocols is harder to read, not easier.

When type hints earn their keep

Type hints pay off most in code that other people read: libraries, shared modules, large applications. They also pay off in code that you will revisit months later. For a script you will run once, they are optional. For a service that 12 people maintain, they are close to essential.

Wrap up

Type hints are notes for humans and tools. Annotate parameters and return types with built-in containers, use | for unions and X | None for optionality, and reach for TypeVar, Protocol, or TypedDict when shapes get richer. Run mypy or pyright, treat their output as feedback rather than law, and adopt gradually on existing code. Done well, type hints make Python feel calmer to work in, especially as a codebase grows beyond what fits in one head.