Master Python type annotations — built-in types, generics, Protocol, TypeVar, runtime validation with Pydantic, and static checking with mypy.
Python's type system is optional but powerful. Adding type hints to your code catches bugs before runtime, enables better IDE autocomplete, and makes large codebases dramatically easier to navigate.
from typing import Optional, Union
# Variables
name: str = "Alice"
age: int = 30
score: float = 9.5
active: bool = True
# Functions
def greet(name: str, times: int = 1) -> str:
return f"Hello, {name}! " * times
# Optional (can be None)
def find_user(user_id: int) -> Optional[str]: # str | None
...
# Union types (Python 3.10+)
def process(value: int | str | None) -> str:
...
# Never returns (raises or loops forever)
from typing import NoReturn
def crash(msg: str) -> NoReturn:
raise RuntimeError(msg)
# Python 3.9+ — use built-in types directly
def process_names(names: list[str]) -> dict[str, int]:
return {name: len(name) for name in names}
# Tuples — fixed length and types
def get_coords() -> tuple[float, float]:
return (51.5, -0.1)
# Tuple of variable length
def get_all_scores() -> tuple[int, ...]:
return (90, 85, 92)
# Sets
def unique_tags(posts: list[dict]) -> set[str]:
return {tag for post in posts for tag in post["tags"]}
# Callable
from typing import Callable
def apply(func: Callable[[int, int], int], a: int, b: int) -> int:
return func(a, b)
# TypedDict — typed dicts
from typing import TypedDict
class UserDict(TypedDict):
id: int
name: str
email: str
def show_user(user: UserDict) -> None:
print(user["name"])
from typing import TypeVar, Generic
T = TypeVar("T")
# Generic function — return type matches input type
def first(items: list[T]) -> T:
return items[0]
x: int = first([1, 2, 3]) # inferred as int
y: str = first(["a", "b"]) # inferred as str
# Generic class
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()
stack: Stack[int] = Stack()
stack.push(42)
# Bounded TypeVar
from typing import SupportsFloat
Numeric = TypeVar("Numeric", int, float, complex)
def add(a: Numeric, b: Numeric) -> Numeric:
return a + b
from typing import Protocol, runtime_checkable
# Protocol defines an interface by behavior, not inheritance
@runtime_checkable
class Drawable(Protocol):
def draw(self) -> None: ...
def resize(self, factor: float) -> None: ...
class Circle:
def draw(self) -> None:
print("Drawing circle")
def resize(self, factor: float) -> None:
self.radius *= factor
class Square:
def draw(self) -> None:
print("Drawing square")
def resize(self, factor: float) -> None:
self.side *= factor
# Circle and Square satisfy Drawable without inheriting from it
def render_all(shapes: list[Drawable]) -> None:
for shape in shapes:
shape.draw()
render_all([Circle(), Square()]) # works!
isinstance(Circle(), Drawable) # True (runtime_checkable)
from pydantic import BaseModel, Field, EmailStr, field_validator
from datetime import datetime
class User(BaseModel):
id: int
name: str = Field(..., min_length=2, max_length=50)
email: EmailStr
age: int = Field(..., ge=0, le=150) # >= 0, <= 150
role: str = "user"
created_at: datetime = Field(default_factory=datetime.utcnow)
@field_validator("name")
@classmethod
def name_must_not_contain_spaces(cls, v: str) -> str:
if " " in v:
raise ValueError("Name cannot have consecutive spaces")
return v.strip()
# Parse from dict (JSON API response)
user = User.model_validate({
"id": 1,
"name": "Alice",
"email": "alice@example.com",
"age": 30
})
# Serialize back to dict / JSON
user.model_dump() # dict
user.model_dump_json() # JSON string
# Parse directly from JSON string
user = User.model_validate_json('{"id": 1, "name": "Bob", ...}')
# Validation errors are descriptive
try:
User(id="not-an-int", name="x", email="bad", age=200)
except ValidationError as e:
print(e.json()) # structured error list
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class CreateUserRequest(BaseModel):
name: str
email: EmailStr
role: Literal["admin", "user"] = "user"
class UserResponse(BaseModel):
id: int
name: str
email: str
created_at: datetime
model_config = {"from_attributes": True} # allow ORM model input
@app.post("/users", response_model=UserResponse, status_code=201)
async def create_user(body: CreateUserRequest) -> UserResponse:
# body is already validated — safe to use
user = await db.users.create(**body.model_dump())
return UserResponse.model_validate(user)
# Install and run
pip install mypy
mypy src/ --strict
# mypy.ini configuration
[mypy]
strict = true
python_version = 3.12
warn_return_any = true
disallow_untyped_defs = true
# Per-module overrides (for third-party libs without stubs)
[mypy-boto3.*]
ignore_missing_imports = true
# Common mypy errors and fixes
# error: Argument 1 to "process" has incompatible type "Optional[str]"
# Fix: narrow the type
if value is not None:
process(value) # value is str here
# error: Return type "None" expected "str"
# Fix: handle the None case
def get_name(user: dict) -> str:
return user.get("name") or "Unknown" # always returns str
# Explicitly tell mypy you know better (use sparingly)
result = some_func() # type: ignore[return-value]