Python Type Hints & Mypy — Static Typing for Better Code

March 2026 · 20 min read · Python, Type Hints, Mypy, Pydantic

Type hints are the best thing that happened to Python since list comprehensions. They catch bugs at development time, make refactoring safe, and serve as living documentation. But most tutorials stop at def foo(x: int) -> str. This guide covers the patterns you'll actually use in production — including the tricky parts.

The Basics (Quick Refresher)

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

# Functions
def greet(name: str, loud: bool = False) -> str:
    msg = f"Hello, {name}!"
    return msg.upper() if loud else msg

# Collections (Python 3.9+, use built-in types)
names: list[str] = ["Alice", "Bob"]
scores: dict[str, float] = {"Alice": 9.5, "Bob": 8.2}
coordinates: tuple[float, float] = (40.7, -74.0)
unique_ids: set[int] = {1, 2, 3}

# Optional (value or None)
from typing import Optional
# Python 3.10+ — use X | None instead
def find_user(user_id: int) -> dict | None:
    return db.get(user_id)  # might return None

# Union types
def process(value: str | int | float) -> str:
    return str(value)
💡 Python 3.10+ simplification: Use X | Y instead of Union[X, Y] and X | None instead of Optional[X]. Cleaner and more readable.

Callable Types

For functions as parameters — common in Strategy and Observer patterns.

from collections.abc import Callable

# Function that takes (str, int) and returns bool
def apply_filter(
    items: list[str],
    predicate: Callable[[str], bool],
) -> list[str]:
    return [item for item in items if predicate(item)]

# Usage
long_names = apply_filter(
    ["Al", "Alexander", "Bob", "Benjamin"],
    lambda name: len(name) > 3,
)

# Callable with no specific signature
from typing import Any
def register(callback: Callable[..., Any]) -> None:
    ...

# More precise: ParamSpec (Python 3.10+)
from typing import ParamSpec, TypeVar

P = ParamSpec("P")
R = TypeVar("R")

def with_logging(fn: Callable[P, R]) -> Callable[P, R]:
    """Decorator that preserves the wrapped function's signature."""
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        print(f"Calling {fn.__name__}")
        return fn(*args, **kwargs)
    return wrapper

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

# mypy knows add(a: int, b: int) -> int — signature preserved!

Generics — Write Type-Safe Reusable Code

from typing import TypeVar, Generic

T = TypeVar("T")


class Stack(Generic[T]):
    """Type-safe stack — Stack[int] only holds ints."""

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

    def __len__(self) -> int:
        return len(self._items)


# Usage — mypy enforces type consistency
int_stack: Stack[int] = Stack()
int_stack.push(42)
int_stack.push(17)
value: int = int_stack.pop()  # mypy knows this is int

str_stack: Stack[str] = Stack()
str_stack.push("hello")
# str_stack.push(42)  # mypy error: Argument 1 has incompatible type "int"

Bounded TypeVars

from typing import TypeVar
from datetime import datetime

# T must be a subclass of str or int
Sortable = TypeVar("Sortable", str, int, float)

def max_value(a: Sortable, b: Sortable) -> Sortable:
    return a if a >= b else b

# Works
max_value(10, 20)        # OK: int
max_value("abc", "xyz")  # OK: str

# Bound to a base class
from typing import TypeVar

class Animal:
    name: str

class Dog(Animal):
    breed: str

A = TypeVar("A", bound=Animal)

def get_name(animal: A) -> str:
    return animal.name  # mypy knows A has .name

Protocol — Structural Typing (Duck Typing with Safety)

Protocol is Python's answer to interfaces — but based on structure, not inheritance. If it walks like a duck and quacks like a duck, mypy accepts it as a duck.

from typing import Protocol, runtime_checkable


class Renderable(Protocol):
    """Anything with a .render() -> str method."""
    def render(self) -> str: ...


class HTMLWidget:
    def render(self) -> str:
        return "<div>widget</div>"


class MarkdownDoc:
    def render(self) -> str:
        return "# Document"


class PlainText:
    def render(self) -> str:
        return "Just text"


def display(item: Renderable) -> None:
    """Accepts ANY object with render() -> str."""
    print(item.render())


# All of these work — no inheritance needed!
display(HTMLWidget())   # ✅
display(MarkdownDoc())  # ✅
display(PlainText())    # ✅
# display(42)           # ❌ mypy error: int has no render()


# Runtime checking with @runtime_checkable
@runtime_checkable
class Closeable(Protocol):
    def close(self) -> None: ...

import io
f = io.StringIO()
print(isinstance(f, Closeable))  # True — StringIO has .close()
💡 Protocol vs ABC: Use Protocol when you want structural typing (anything with the right methods works). Use ABC when you want nominal typing (must explicitly inherit). Protocol is more Pythonic for most cases.

TypedDict — Type-Safe Dictionaries

from typing import TypedDict, NotRequired


class UserConfig(TypedDict):
    username: str
    email: str
    theme: NotRequired[str]           # Optional key (Python 3.11+)
    notifications: NotRequired[bool]


def save_config(config: UserConfig) -> None:
    print(f"Saving config for {config['username']}")


# ✅ Valid
save_config({"username": "alice", "email": "a@b.com"})
save_config({"username": "bob", "email": "b@c.com", "theme": "dark"})

# ❌ mypy catches these
# save_config({"username": "alice"})  # Missing required key 'email'
# save_config({"username": "alice", "email": "a@b.com", "age": 30})  # Extra key


# Nested TypedDicts
class Address(TypedDict):
    street: str
    city: str
    country: str

class UserProfile(TypedDict):
    name: str
    address: Address
    tags: list[str]

Literal, Final, and NewType

from typing import Literal, Final, NewType

# Literal — restrict to specific values
def set_log_level(level: Literal["debug", "info", "warning", "error"]) -> None:
    print(f"Log level: {level}")

set_log_level("info")     # ✅
# set_log_level("verbose")  # ❌ mypy error


# Final — constant that can't be reassigned
MAX_RETRIES: Final = 3
API_VERSION: Final[str] = "v2"
# MAX_RETRIES = 5  # ❌ mypy error: Cannot assign to final name


# NewType — create distinct types from existing ones
UserId = NewType("UserId", int)
OrderId = NewType("OrderId", int)

def get_user(user_id: UserId) -> dict:
    return {"id": user_id}

uid = UserId(42)
oid = OrderId(42)

get_user(uid)  # ✅
# get_user(oid)  # ❌ mypy error: expected UserId, got OrderId
# get_user(42)   # ❌ mypy error: expected UserId, got int

Overload — Multiple Signatures

from typing import overload


@overload
def process(data: str) -> list[str]: ...
@overload
def process(data: bytes) -> list[bytes]: ...
@overload
def process(data: list[str]) -> str: ...

def process(data):
    """Implementation handles all cases."""
    if isinstance(data, str):
        return data.split()
    elif isinstance(data, bytes):
        return data.split(b" ")
    elif isinstance(data, list):
        return " ".join(data)
    raise TypeError(f"Unsupported type: {type(data)}")


# mypy knows the return types!
words: list[str] = process("hello world")     # ✅ list[str]
chunks: list[bytes] = process(b"hello world")  # ✅ list[bytes]
sentence: str = process(["hello", "world"])    # ✅ str

Pydantic — Runtime Type Validation

Type hints alone are only checked by mypy (at dev time). Pydantic validates at runtime — crucial for API input, config files, and external data. See our FastAPI guide for Pydantic in APIs.

from pydantic import BaseModel, Field, field_validator, model_validator
from datetime import datetime


class CreateUser(BaseModel):
    name: str = Field(..., min_length=1, max_length=100)
    email: str = Field(..., pattern=r"^[\w.-]+@[\w.-]+\.\w+$")
    age: int = Field(..., ge=0, le=150)
    role: Literal["admin", "user", "viewer"] = "user"
    tags: list[str] = []

    @field_validator("name")
    @classmethod
    def name_must_be_titlecase(cls, v: str) -> str:
        return v.strip().title()

    @field_validator("tags")
    @classmethod
    def validate_tags(cls, v: list[str]) -> list[str]:
        return [tag.lower().strip() for tag in v if tag.strip()]


# ✅ Valid — auto-coerces and validates
user = CreateUser(
    name="  alice  ",
    email="alice@example.com",
    age=30,
    tags=["Python", " FastAPI ", ""],
)
print(user.name)   # "Alice" (stripped + titlecased)
print(user.tags)   # ["python", "fastapi"] (lowercased, empty removed)

# ❌ Raises ValidationError
try:
    CreateUser(name="", email="not-an-email", age=-5)
except Exception as e:
    print(e)
    # name: String should have at least 1 character
    # email: String should match pattern ...
    # age: Input should be greater than or equal to 0

Model composition

class Address(BaseModel):
    street: str
    city: str
    country: str = "US"

class Company(BaseModel):
    name: str
    founded: int

class Employee(BaseModel):
    name: str
    address: Address
    company: Company
    start_date: datetime

# Pydantic handles nested validation + JSON serialization
emp = Employee(
    name="Bob",
    address={"street": "123 Main St", "city": "NYC"},  # dict auto-parsed
    company={"name": "Acme", "founded": 2020},
    start_date="2026-01-15T09:00:00",  # string auto-parsed to datetime
)

print(emp.model_dump_json(indent=2))
# Full JSON with nested objects, dates as ISO strings

Mypy Configuration

# pyproject.toml — recommended mypy config
[tool.mypy]
python_version = "3.12"
strict = true                    # Enable all strict checks
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true     # Every function must have type hints
disallow_any_generics = true     # No bare list, dict — must be list[int], dict[str, int]
check_untyped_defs = true
no_implicit_optional = true      # def f(x: str = None) is an error — use str | None

# Per-module overrides
[[tool.mypy.overrides]]
module = "tests.*"
disallow_untyped_defs = false    # Tests can be less strict

[[tool.mypy.overrides]]
module = "third_party_lib.*"
ignore_missing_imports = true    # No stubs available
# Run mypy
pip install mypy

# Check a file
mypy app/main.py

# Check entire project
mypy app/

# With useful flags
mypy app/ --show-error-codes --pretty

# Example output:
# app/service.py:42: error: Argument 1 to "process" has
#   incompatible type "int"; expected "str"  [arg-type]
# app/service.py:55: error: Missing return statement  [return]

Common mypy errors and fixes

# 1. "Item X has no attribute Y" — narrow the type
def handle(value: str | int) -> str:
    # ❌ value.upper()  — int has no .upper()
    if isinstance(value, str):
        return value.upper()  # ✅ mypy knows it's str here
    return str(value)

# 2. "Incompatible return type" — check all paths
def find(items: list[str], target: str) -> int:
    for i, item in enumerate(items):
        if item == target:
            return i
    return -1  # ✅ Don't forget the else path

# 3. "Cannot infer type" — add annotation
# ❌ data = []
data: list[str] = []  # ✅

# 4. "Missing type annotation for **kwargs"
def config(**kwargs: str) -> None:  # ✅ All kwargs are str
    ...

# 5. Ignoring a line (last resort)
result = sketchy_function()  # type: ignore[no-untyped-call]

Type Hints Cheat Sheet

TypeExampleSince
Basicint, str, float, bool, bytes, None3.5
Listlist[str]3.9
Dictdict[str, int]3.9
Tuple (fixed)tuple[str, int, float]3.9
Tuple (variable)tuple[str, ...]3.9
Setset[int]3.9
Unionstr | int3.10
Optionalstr | None3.10
CallableCallable[[int, str], bool]3.9
TypeVarT = TypeVar("T")3.5
Protocolclass P(Protocol): ...3.8
TypedDictclass TD(TypedDict): ...3.8
LiteralLiteral["a", "b"]3.8
FinalMAX: Final = 1003.8
NewTypeUserId = NewType("UserId", int)3.5
Selfdef copy(self) -> Self3.11
ParamSpecP = ParamSpec("P")3.10
type statementtype Vector = list[float]3.12
🎯 Adoption strategy: Don't type-hint your entire codebase at once. Start with public APIs and function signatures. Add mypy to CI with --strict on new files only. Gradually expand coverage.

🚀 Want production Python templates with full type coverage and mypy CI setup?

Get the AI Agent Toolkit →

Related Articles

Need help adding type safety to your Python project? I build well-typed APIs, automation tools, and data pipelines. Reach out on Telegram →