Python Type Hints and Pydantic: Write Safer, More Readable Python Code
Python's dynamic typing is both its strength and its weakness. Type hints, introduced in Python 3.5 and massively improved since, give you the best of both worlds: the flexibility of dynamic typing with the safety of static analysis. Pydantic takes type hints further by enforcing them at runtime. Together they are the foundation of modern Python development.
Basic Type Annotations
python-- 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).strip() def process_items(items: list[int]) -> dict[str, int]: return {"sum": sum(items), "count": len(items)} -- Python 3.10+ union syntax (preferred over Union[]) def format_value(value: int | float | None) -> str: if value is None: return "N/A" return f"{value:.2f}"
Collections and Generics
pythonfrom typing import Optional, Union, Any -- Built-in generics (Python 3.9+) def top_scores(scores: list[float], n: int) -> list[float]: return sorted(scores, reverse=True)[:n] def merge_configs(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: return {**base, **override} -- Tuple with fixed structure def get_coords() -> tuple[float, float]: return (51.5074, -0.1278) -- Tuple with variable length def get_tags() -> tuple[str, ...]: return ("python", "typing", "pydantic") -- Set def unique_tags(posts: list[dict]) -> set[str]: return {tag for post in posts for tag in post.get("tags", [])} -- Optional is just Union with None def find_user(user_id: int) -> Optional[str]: -- str | None users = {1: "Alice", 2: "Bob"} return users.get(user_id)
TypedDict
Define the shape of dictionaries at the type level:
pythonfrom typing import TypedDict, NotRequired class Address(TypedDict): street: str city: str country: str zip_code: NotRequired[str] -- optional key class UserDict(TypedDict): id: int name: str email: str address: Address def format_address(user: UserDict) -> str: addr = user["address"] return f"{addr['street']}, {addr['city']}, {addr['country']}" -- Type checker catches wrong key names user: UserDict = { "id": 1, "name": "Alice", "email": "alice@example.com", "address": {"street": "123 Main St", "city": "London", "country": "UK"}, }
Protocol: Structural Subtyping (Duck Typing with Types)
pythonfrom typing import Protocol, runtime_checkable @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 Rectangle: def draw(self) -> None: print("Drawing rectangle") def resize(self, factor: float) -> None: self.width *= factor self.height *= factor -- Both satisfy Drawable without explicit inheritance def render_all(shapes: list[Drawable]) -> None: for shape in shapes: shape.draw() render_all([Circle(), Rectangle()]) -- type checker is happy -- Runtime check print(isinstance(Circle(), Drawable)) -- True
Generic Functions and Classes
pythonfrom typing import TypeVar, Generic T = TypeVar("T") K = TypeVar("K") V = TypeVar("V") -- Generic function def first(items: list[T]) -> T | None: return items[0] if items else None first([1, 2, 3]) -- int | None first(["a", "b"]) -- str | None -- Generic class class Result(Generic[T]): def __init__(self, value: T | None, error: str | None = None): self.value = value self.error = error self.ok = error is None @classmethod def success(cls, value: T) -> "Result[T]": return cls(value=value) @classmethod def failure(cls, error: str) -> "Result[T]": return cls(value=None, error=error) result: Result[int] = Result.success(42) if result.ok: print(result.value + 1) -- type checker knows value is int
Pydantic v2 Basics
Pydantic validates data at runtime using type annotations:
pythonfrom pydantic import BaseModel, EmailStr, Field, field_validator, model_validator from datetime import datetime from typing import Optional class UserCreate(BaseModel): username: str = Field(min_length=3, max_length=50, pattern=r"^[a-zA-Z0-9_]+$") email: EmailStr password: str = Field(min_length=8) age: Optional[int] = Field(default=None, ge=0, le=150) tags: list[str] = Field(default_factory=list) @field_validator("username") @classmethod def username_lowercase(cls, v: str) -> str: return v.lower() @field_validator("password") @classmethod def password_complexity(cls, v: str) -> str: if not any(c.isupper() for c in v): raise ValueError("Password must contain at least one uppercase letter") if not any(c.isdigit() for c in v): raise ValueError("Password must contain at least one digit") return v @model_validator(mode="after") def username_not_in_password(self) -> "UserCreate": if self.username in self.password.lower(): raise ValueError("Password must not contain username") return self -- Valid data user = UserCreate( username="Alice", email="alice@example.com", password="SecurePass123", age=30, ) print(user.username) -- "alice" (lowercased by validator) -- Invalid data raises ValidationError try: UserCreate(username="a", email="not-an-email", password="weak") except ValidationError as e: print(e.json(indent=2)) -- Detailed field-level errors
Pydantic Models: Serialization and Configuration
pythonfrom pydantic import BaseModel, ConfigDict, computed_field from datetime import datetime class UserResponse(BaseModel): model_config = ConfigDict( from_attributes=True, -- enable ORM mode (read from SQLAlchemy models) populate_by_name=True, -- allow field name or alias str_strip_whitespace=True, ) id: int username: str email: str created_at: datetime full_name: str = Field(alias="name") -- accept "name" from input @computed_field @property def display_name(self) -> str: return f"@{self.username}" -- Serialize to dict/JSON user = UserResponse(id=1, username="alice", email="a@b.com", created_at=datetime.now(), name="Alice Smith") user.model_dump() -- {"id": 1, "username": "alice", "email": "a@b.com", ...} user.model_dump(include={"id", "username"}) -- {"id": 1, "username": "alice"} user.model_dump(exclude={"email"}) user.model_dump_json() -- JSON string directly -- From ORM object class UserORM: id = 1 username = "alice" email = "a@b.com" created_at = datetime.now() name = "Alice Smith" UserResponse.model_validate(UserORM()) -- reads attributes, not dict keys
Pydantic Settings
Manage application configuration with type safety and environment variable loading:
pythonfrom pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", case_sensitive=False, ) -- Required (no default -- must be set in env or .env) database_url: str secret_key: str -- Optional with defaults debug: bool = False port: int = 8000 allowed_hosts: list[str] = ["localhost"] max_connections: int = Field(default=10, ge=1, le=100) -- Nested settings redis_host: str = "localhost" redis_port: int = 6379 @property def redis_url(self) -> str: return f"redis://{self.redis_host}:{self.redis_port}" -- Singleton pattern for settings from functools import lru_cache @lru_cache def get_settings() -> Settings: return Settings() -- Usage settings = get_settings() print(settings.database_url) print(settings.redis_url)
Common Interview Questions
Q: What is the difference between type hints and runtime type checking in Python?
Type hints are annotations that type checkers (mypy, pyright) analyse statically β at development time. Python itself ignores them at runtime; they are just metadata. Pydantic adds runtime enforcement: when you instantiate a Pydantic model with invalid data, it raises a ValidationError immediately. Type hints catch errors before running code; Pydantic catches errors when the code actually runs with real data.
Q: What is a Protocol and how is it different from ABC?
An Abstract Base Class requires explicit inheritance (class Dog(Animal)). A Protocol uses structural subtyping β any class that implements the required methods satisfies the Protocol, without needing to inherit from it. This is Python's formalization of duck typing. Use Protocol when you want to define an interface without forcing inheritance; use ABC when you want to provide default implementations or enforce a class hierarchy.
Q: When would you use TypedDict over a Pydantic model?
TypedDict is a lightweight type-only construct β it has zero runtime overhead and is just a hint for type checkers. Use it for describing the shape of existing dictionaries (like JSON responses from external APIs) where you do not control the construction. Use Pydantic when you need runtime validation, default values, computed fields, serialization, or settings management.
Practice Python on Froquiz
Type hints and modern Python patterns are tested in Python developer interviews. Test your Python skills on Froquiz β covering OOP, async, decorators, and more.
Summary
- Type hints are static metadata β use
str | None(3.10+) instead ofOptional[str] TypedDictdescribes dictionary shapes for type checkers with zero runtime costProtocolenables structural subtyping β duck typing with static analysis supportTypeVarandGenericenable reusable type-safe functions and classes- Pydantic v2 validates data at runtime using type annotations β raises
ValidationErroron bad input @field_validatorvalidates individual fields;@model_validatorvalidates cross-field logic- enables reading from ORM objectscode
model_config = ConfigDict(from_attributes=True) pydantic-settingsloads typed configuration from environment variables and.envfiles