Python Type Hints Masterclass: Write Bulletproof Code
Type hints aren't just documentation — they catch bugs before they reach production. Here's how to use them effectively.
Why Type Hints Matter
- Catch bugs at write time: IDE catches errors before runtime
- Better documentation: Types are self-documenting
- Performance: Tools like mypy and pyright optimize based on types
- Refactoring safety: Change code confidently with type checking
Basic Types
from typing import Optional, Union
# Simple types
name: str = "Anna"
age: int = 25
price: float = 29.99
active: bool = True
# Collections
scores: list[float] = [95.5, 87.3, 91.0]
user_ids: set[int] = {1, 2, 3}
metadata: dict[str, str] = {"key": "value"}
# Optional (can be None)
middle_name: Optional[str] = None # Same as str | None
# Union (multiple types)
response: Union[str, int] = "ok" # Same as str | int
Function Signatures
from typing import Callable, Awaitable
# Always type function signatures
def calculate_discount(price: float, percentage: float) -> float:
return price * (1 - percentage / 100)
# Async functions
async def fetch_user(user_id: int) -> dict:
return await db.get_user(user_id)
# Functions that accept functions
def apply_transform(data: list[int], fn: Callable[[int], int]) -> list[int]:
return [fn(x) for x in data]
Pydantic Models (Best Practice)
from pydantic import BaseModel, Field, EmailStr
from datetime import datetime
class User(BaseModel):
id: int
name: str = Field(..., min_length=1, max_length=100)
email: EmailStr
age: int = Field(..., ge=0, le=150)
created_at: datetime = Field(default_factory=datetime.now)
tags: list[str] = []
# Validation happens automatically
user = User(id=1, name="Anna", email="anna@example.com", age=25)
# User(id=1, name='Anna', email='anna@example.com', age=25, ...)
# This raises ValidationError
bad_user = User(id="not_a_number", name="", email="invalid")
Generics
from typing import TypeVar, Generic
T = TypeVar("T")
class Repository(Generic[T]):
def __init__(self, items: list[T] | None = None):
self._items = items or []
def add(self, item: T) -> None:
self._items.append(item)
def get(self, index: int) -> T:
return self._items[index]
def find(self, predicate: Callable[[T], bool]) -> T | None:
return next((item for item in self._items if predicate(item)), None)
# Type-safe repositories
user_repo: Repository[User] = Repository()
user_repo.add(User(id=1, name="Anna", email="a@b.com", age=25))
user = user_repo.get(0) # Type is User, not dict
Protocols (Structural Subtyping)
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> str: ...
class Circle:
def draw(self) -> str:
return "🔴"
class Square:
def draw(self) -> str:
return "🟧"
# Any object with draw() works - no inheritance needed
def render(shape: Drawable) -> str:
return shape.draw()
render(Circle()) # Works
render(Square()) # Works
render("hello") # Type error: str has no draw()
Literal Types
from typing import Literal
def set_direction(direction: Literal["north", "south", "east", "west"]) -> None:
print(f"Moving {direction}")
set_direction("north") # Works
set_direction("up") # Type error
# Useful for API parameters
HTTPMethod = Literal["GET", "POST", "PUT", "DELETE", "PATCH"]
def request(method: HTTPMethod, url: str) -> dict:
...
TypedDict
from typing import TypedDict
class UserDict(TypedDict):
id: int
name: str
email: str
def process_user(user: UserDict) -> str:
return f"{user['name']} ({user['email']})"
# Catches missing/extra keys at type check time
user: UserDict = {"id": 1, "name": "Anna", "email": "a@b.com"}
process_user(user) # Works
bad_user: UserDict = {"id": 1, "name": "Anna"} # Missing 'email'
process_user(bad_user) # Type error
Type Guard
from typing import TypeGuard
def is_string_list(val: list[object]) -> TypeGuard[list[str]]:
return all(isinstance(x, str) for x in val)
def process(data: list[object]) -> None:
if is_string_list(data):
# Type checker knows data is list[str] here
print(" ".join(data))
else:
print("Not all strings")
Real-World Example: API Endpoint
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
class CreateUserRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
email: EmailStr
password: str = Field(..., min_length=8)
class UserResponse(BaseModel):
id: int
name: str
email: str
class ErrorResponse(BaseModel):
detail: str
code: str
app = FastAPI()
@app.post(
"/users",
response_model=UserResponse,
responses={400: {"model": ErrorResponse}},
)
async def create_user(req: CreateUserRequest) -> UserResponse:
# Type checker knows req.name is str, req.email is EmailStr
existing = await db.find_user(email=req.email)
if existing:
raise HTTPException(400, "Email already registered")
user = await db.create_user(**req.model_dump())
return UserResponse(id=user.id, name=user.name, email=user.email)
MyPy Configuration
# mypy.ini
[mypy]
python_version = 3.12
strict = true
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
check_untyped_defs = true
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
Key Takeaways
- Type everything: Functions, variables, return values
- Use Pydantic for data validation: Don't validate manually
- Generics for reusable code: Write type-safe containers
- Protocols over inheritance: Structural typing is more flexible
- Run mypy in CI: Catch type errors before merge
- Type hints are documentation: They tell other developers what your code expects
Type hints are an investment that pays dividends in code quality, maintainability, and developer productivity.
Get the Production-Ready Version
Don't want to build it yourself? We have production-ready versions of these tools at https://reply-continues-exams-confidential.trycloudflare.com.
What you get:
- Complete, tested Python code
- Documentation and setup guides
- Instant delivery after crypto payment
- Free updates
Top comments (0)