Python Type Hints and Pydantic: Complete Guide to Type Safety

!DOCTYPE html> Python Type Hints and Pydantic v2: Write Safer, Self-Documenting Code

Python Type Hints and Pydantic v2: Write Safer, Self-Documenting Code

Type hints catch bugs before runtime. Pydantic enforces them at the boundary. Together they transform Python from a duck-typed scripting language into a reliable production platform.

Why Type Hints Matter in Production

Python's dynamic typing is great for prototyping. But in a 50,000-line codebase with 10 engineers, it becomes a liability. Type hints solve three concrete problems:

  • Catch bugs at write time -- your editor/mypy flags process(user_id="abc") when process(user_id: int) is expected
  • Self-documenting code -- function signatures become contracts, eliminating guesswork
  • IDE superpowers -- full autocomplete, rename refactoring, and go-to-definition across the codebase

Modern Type Hint Syntax (Python 3.10+)

# Old way (still valid, but verbose)
from typing import Optional, Union, List, Dict, Tuple

def old_style(
    name: Optional[str],
    ids:  List[int],
    meta: Dict[str, Union[str, int]]
) -> Optional[Dict[str, List[int]]]:
    ...

# Modern way (Python 3.10+) -- cleaner, same semantics
def modern_style(
    name: str | None,
    ids:  list[int],
    meta: dict[str, str | int]
) -> dict[str, list[int]] | None:
    ...

Essential Built-in Types

from typing import Any, Never, TypeAlias, TypeVar, Callable, Awaitable

# Type aliases
UserId:   TypeAlias = int
JsonDict: TypeAlias = dict[str, Any]

# TypeVar for generic functions
T = TypeVar("T")

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

first([1, 2, 3])      # inferred return: int
first(["a", "b"])     # inferred return: str

# Callable type hints
Handler = Callable[[str, int], bool]

def run_handler(h: Handler, name: str, value: int) -> bool:
    return h(name, value)

# Literal -- restrict to specific values
from typing import Literal

def set_mode(mode: Literal["read", "write", "append"]) -> None: ...

Generics and Protocols

from typing import Protocol, TypeVar, Generic, runtime_checkable

# Protocol -- structural subtyping (duck typing with type safety)
@runtime_checkable
class Serializable(Protocol):
    def to_dict(self) -> dict[str, Any]: ...
    def to_json(self) -> str: ...

class User:
    def __init__(self, id: int, name: str):
        self.id, self.name = id, name
    def to_dict(self) -> dict[str, Any]:
        return {"id": self.id, "name": self.name}
    def to_json(self) -> str:
        import json; return json.dumps(self.to_dict())

def export(obj: Serializable) -> str:
    return obj.to_json()  # works with any class that has these methods

# Generic class
T = TypeVar("T")

class Repository(Generic[T]):
    def __init__(self):
        self._store: dict[int, T] = {}

    def save(self, id: int, obj: T) -> None:
        self._store[id] = obj

    def get(self, id: int) -> T | None:
        return self._store.get(id)

user_repo = Repository[User]()
user_repo.save(1, User(1, "Alice"))
user: User | None = user_repo.get(1)  # fully typed

Pydantic v2 Models

pip install pydantic[email]
from pydantic import BaseModel, EmailStr, HttpUrl, Field, computed_field
from datetime import datetime
from enum import Enum
from typing import Annotated

class Role(str, Enum):
    admin  = "admin"
    editor = "editor"
    viewer = "viewer"

# Annotated types -- reusable constraints
PositiveInt = Annotated[int, Field(gt=0)]
ShortStr    = Annotated[str, Field(min_length=1, max_length=100)]

class UserCreate(BaseModel):
    email:      EmailStr
    username:   ShortStr
    age:        PositiveInt
    role:       Role = Role.viewer
    website:    HttpUrl | None = None
    tags:       list[str]     = Field(default_factory=list)

    model_config = {
        "str_strip_whitespace": True,
        "str_to_lower": False,
    }

class UserResponse(UserCreate):
    id:         int
    created_at: datetime
    is_active:  bool = True

    @computed_field
    @property
    def display_name(self) -> str:
        return f"@{self.username}"

# Validation at instantiation
try:
    user = UserCreate(
        email="alice@example.com",
        username="alice",
        age=30,
        role="admin"
    )
    print(user.model_dump())
except ValueError as e:
    print(e)  # Pydantic gives detailed field-level errors

Nested Models and Serialisation

class Address(BaseModel):
    street: str
    city:   str
    country: str = "IN"
    postcode: str | None = None

class Organisation(BaseModel):
    id:       int
    name:     ShortStr
    address:  Address
    members:  list[UserResponse] = []

org = Organisation(
    id=1, name="Acme Corp",
    address={"street": "123 Main St", "city": "Bangalore"}
)

# Serialise
print(org.model_dump())
print(org.model_dump_json(indent=2))

# Deserialise from JSON
raw = '{"id":1,"name":"Acme","address":{"street":"1 MG","city":"Bengaluru"}}'
org2 = Organisation.model_validate_json(raw)

Custom Validators

from pydantic import BaseModel, field_validator, model_validator, Field

class DateRange(BaseModel):
    start_date: datetime
    end_date:   datetime
    max_days:   int = Field(default=365, gt=0)

    @field_validator("end_date")
    @classmethod
    def end_must_be_future(cls, v: datetime) -> datetime:
        if v <= datetime.utcnow():
            raise ValueError("end_date must be in the future")
        return v

    @model_validator(mode="after")
    def range_valid(self) -> "DateRange":
        delta = (self.end_date - self.start_date).days
        if delta <= 0:
            raise ValueError("end_date must be after start_date")
        if delta > self.max_days:
            raise ValueError(f"Range exceeds {self.max_days} days (got {delta})")
        return self

Settings Management with BaseSettings

pip install pydantic-settings
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import PostgresDsn, RedisDsn, SecretStr

class Settings(BaseSettings):
    # Reads from environment variables or .env file
    app_name:     str         = "MyApp"
    debug:        bool        = False
    database_url: PostgresDsn
    redis_url:    RedisDsn    = "redis://localhost:6379/0"
    secret_key:   SecretStr               # never printed in logs
    allowed_hosts:list[str]  = ["*"]
    max_workers:  int         = Field(default=4, ge=1, le=64)

    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        case_sensitive=False,
    )

# Singleton pattern
from functools import lru_cache

@lru_cache
def get_settings() -> Settings:
    return Settings()

settings = get_settings()
print(settings.database_url)
print(settings.secret_key.get_secret_value())  # explicit access required

mypy and pyright Static Analysis

# pyproject.toml
[tool.mypy]
python_version   = "3.12"
strict           = true
ignore_missing_imports = true
plugins          = ["pydantic.mypy"]

[tool.pyright]
pythonVersion    = "3.12"
typeCheckingMode = "strict"
# Run checks
mypy src/           # full project check
pyright src/        # Microsoft's faster alternative

# Common mypy flags
mypy --strict src/
mypy --ignore-missing-imports src/
[WARN] Common gotcha: dict[str, Any] silences the type checker for that entire subtree. Prefer explicit models (Pydantic BaseModel or TypedDict) over Any wherever possible.
[YES] Adoption strategy: Start with mypy --ignore-missing-imports on new files only. Use # type: ignore sparingly to unblock -- but add a comment explaining why. Gradually increase strictness as the codebase matures.

Key Takeaways: Use modern union syntax (X | None) over Optional[X]. Define Protocols for structural typing. Use Pydantic at every data boundary -- API input, config, DB serialisation. Enforce with mypy in CI.

PythonType HintsPydanticmypyStatic AnalysisClean Code

Post a Comment

0 Comments