Python’s dynamic typing is a double-edged sword. It makes prototyping fast but can turn large codebases into guessing games. Type hints, introduced in PEP 484 (Python 3.5), let you add optional static typing while keeping Python’s flexibility. When combined with tools like mypy or pyright, they catch bugs before your code ever runs.

Why Type Hints?

Consider this function:

def process_order(order, discount):
    total = order["total"] * (1 - discount)
    if order["priority"]:
        total *= 0.95
    return {"amount": total, "status": "processed"}

What type is order? What keys does it have? What’s discount — a float between 0 and 1, or a percentage? What does the function return? Without reading the implementation line by line, you can’t know.

With type hints:

from typing import TypedDict

class Order(TypedDict):
    id: str
    total: float
    priority: bool

class ProcessedOrder(TypedDict):
    amount: float
    status: str

def process_order(order: Order, discount: float) -> ProcessedOrder:
    total = order["total"] * (1 - discount)
    if order["priority"]:
        total *= 0.95
    return {"amount": total, "status": "processed"}

Now the function is self-documenting. Your IDE autocompletes dict keys. Mypy catches errors statically.

Essential Type Hints

Basic Types

name: str = "Alice"
age: int = 30
price: float = 19.99
active: bool = True
data: bytes = b"raw"

Collections (Python 3.9+)

# Use built-in types directly (no need for typing.List, typing.Dict)
names: list[str] = ["Alice", "Bob"]
scores: dict[str, int] = {"Alice": 100, "Bob": 85}
coordinates: tuple[float, float] = (48.8566, 2.3522)
unique_ids: set[str] = {"a1", "b2", "c3"}

# Variable-length tuples
values: tuple[int, ...] = (1, 2, 3, 4, 5)

Union and Optional

# Python 3.10+ syntax
def find_user(user_id: str) -> User | None:
    ...

# Equivalent to Optional[User]
def parse_int(value: str) -> int | None:
    try:
        return int(value)
    except ValueError:
        return None

# Union of multiple types
def normalize(value: str | int | float) -> str:
    return str(value).strip()

Callable Types

from collections.abc import Callable

# A function that takes (str, int) and returns bool
Validator = Callable[[str, int], bool]

def apply_validators(value: str, validators: list[Validator]) -> bool:
    return all(v(value, len(value)) for v in validators)

# With default arguments — use Protocol instead
from typing import Protocol

class Formatter(Protocol):
    def __call__(self, text: str, *, uppercase: bool = False) -> str: ...

TypedDict: Typed Dictionaries

For structured dictionaries (common with JSON APIs):

from typing import TypedDict, NotRequired

class UserProfile(TypedDict):
    id: str
    name: str
    email: str
    avatar: NotRequired[str]  # Optional field (Python 3.11+)

def create_user(data: UserProfile) -> None:
    print(data["name"])      # ✅ Autocomplete works
    print(data["phone"])     # ❌ mypy error: TypedDict has no key 'phone'

Protocols: Structural Subtyping

Protocols define what an object can do without requiring inheritance. This is Python’s answer to interfaces:

from typing import Protocol, runtime_checkable

@runtime_checkable
class Renderable(Protocol):
    def render(self) -> str: ...

class MarkdownDoc:
    def __init__(self, content: str):
        self.content = content

    def render(self) -> str:
        return f"<p>{self.content}</p>"

class JSONResponse:
    def __init__(self, data: dict):
        self.data = data

    def render(self) -> str:
        import json
        return json.dumps(self.data)

# Both work — no base class needed
def output(item: Renderable) -> None:
    print(item.render())

output(MarkdownDoc("Hello"))     # ✅
output(JSONResponse({"ok": True}))  # ✅
output("plain string")            # ❌ mypy error: str has no render()

Protocols encourage duck typing with safety. If it has a render() method returning str, it’s Renderable.

Generics

Generic Functions

from typing import TypeVar

T = TypeVar("T")

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

# TypeScript knows the return type:
result = first([1, 2, 3])        # int | None
name = first(["a", "b"])         # str | None

Generic Classes

from typing import Generic, TypeVar

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:
        if not self._items:
            raise IndexError("Stack is empty")
        return self._items.pop()

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

# Usage
int_stack: Stack[int] = Stack()
int_stack.push(42)
int_stack.push("oops")  # ❌ mypy error: expected int, got str

Bounded TypeVars

from typing import TypeVar
from collections.abc import Sized

S = TypeVar("S", bound=Sized)

def longest(a: S, b: S) -> S:
    return a if len(a) >= len(b) else b

longest([1, 2], [3, 4, 5])    # ✅ list[int]
longest("abc", "de")           # ✅ str
longest(42, 100)               # ❌ int has no len()

TypeGuard: Custom Type Narrowing

from typing import TypeGuard

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

def process(data: list[object]) -> None:
    if is_string_list(data):
        # mypy knows data is list[str] here
        joined = ", ".join(data)  # ✅ No error
        print(joined)

Literal Types

Constrain values to specific literals:

from typing import Literal

LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR"]

def log(message: str, level: LogLevel = "INFO") -> None:
    print(f"[{level}] {message}")

log("Starting", "INFO")       # ✅
log("Oops", "CRITICAL")       # ❌ mypy error

Overloaded Functions

When a function’s return type depends on its arguments:

from typing import overload

@overload
def parse(raw: str, as_list: Literal[True]) -> list[str]: ...
@overload
def parse(raw: str, as_list: Literal[False]) -> str: ...
@overload
def parse(raw: str) -> str: ...

def parse(raw: str, as_list: bool = False) -> str | list[str]:
    if as_list:
        return raw.split(",")
    return raw.strip()

result1 = parse("a,b,c", as_list=True)   # list[str]
result2 = parse("hello")                   # str

Setting Up Mypy

Install and configure mypy for strict checking:

# pyproject.toml
[tool.mypy]
python_version = "3.12"
strict = true
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_any_generics = true
check_untyped_defs = true
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true

Run it:

mypy src/
# Success: no issues found in 42 source files

Gradual Adoption

You don’t have to type everything at once. Start with:

  1. New code — require type hints for all new modules
  2. Public APIs — type your function signatures first
  3. Critical paths — add types to business logic and data transformations
  4. Use # type: ignore sparingly for legacy code you’ll fix later

Common Patterns

Result Type (No Exceptions)

from dataclasses import dataclass
from typing import Generic, TypeVar

T = TypeVar("T")
E = TypeVar("E")

@dataclass(frozen=True)
class Ok(Generic[T]):
    value: T

@dataclass(frozen=True)
class Err(Generic[E]):
    error: E

Result = Ok[T] | Err[E]

def divide(a: float, b: float) -> Result[float, str]:
    if b == 0:
        return Err("Division by zero")
    return Ok(a / b)

match divide(10, 3):
    case Ok(value):
        print(f"Result: {value}")
    case Err(error):
        print(f"Error: {error}")

Key Takeaways

  1. Type hints are documentation that doesn’t go stale — they’re checked by tools.
  2. Start with function signatures — the highest impact for the least effort.
  3. Use Protocols over ABCs — structural typing is more Pythonic.
  4. TypedDict for JSON shapes — catches key typos at the type level.
  5. Gradual adoption works — you don’t need 100% coverage to get value.
  6. Run mypy in CI — make it fail the build on type errors.

Type hints don’t make Python into Java. They give you a safety net while keeping everything that makes Python great.