Python Type Safety
Leverage Python's type system to catch errors at static analysis time. Type annotations serve as enforced documentation that tooling validates automatically.
When to Use This Skill
-
Adding type hints to existing code
-
Creating generic, reusable classes
-
Defining structural interfaces with protocols
-
Configuring mypy or pyright for strict checking
-
Understanding type narrowing and guards
-
Building type-safe APIs and libraries
Core Concepts
- Type Annotations
Declare expected types for function parameters, return values, and variables.
- Generics
Write reusable code that preserves type information across different types.
- Protocols
Define structural interfaces without inheritance (duck typing with type safety).
- Type Narrowing
Use guards and conditionals to narrow types within code blocks.
Quick Start
def get_user(user_id: str) -> User | None: """Return type makes 'might not exist' explicit.""" ...
Type checker enforces handling None case
user = get_user("123") if user is None: raise UserNotFoundError("123") print(user.name) # Type checker knows user is User here
Fundamental Patterns
Pattern 1: Annotate All Public Signatures
Every public function, method, and class should have type annotations.
def get_user(user_id: str) -> User: """Retrieve user by ID.""" ...
def process_batch( items: list[Item], max_workers: int = 4, ) -> BatchResult[ProcessedItem]: """Process items concurrently.""" ...
class UserRepository: def init(self, db: Database) -> None: self._db = db
async def find_by_id(self, user_id: str) -> User | None:
"""Return User if found, None otherwise."""
...
async def find_by_email(self, email: str) -> User | None:
...
async def save(self, user: User) -> User:
"""Save and return user with generated ID."""
...
Use mypy --strict or pyright in CI to catch type errors early. For existing projects, enable strict mode incrementally using per-module overrides.
Pattern 2: Use Modern Union Syntax
Python 3.10+ provides cleaner union syntax.
Preferred (3.10+)
def find_user(user_id: str) -> User | None: ...
def parse_value(v: str) -> int | float | str: ...
Older style (still valid, needed for 3.9)
from typing import Optional, Union
def find_user(user_id: str) -> Optional[User]: ...
Pattern 3: Type Narrowing with Guards
Use conditionals to narrow types for the type checker.
def process_user(user_id: str) -> UserData: user = find_user(user_id)
if user is None:
raise UserNotFoundError(f"User {user_id} not found")
# Type checker knows user is User here, not User | None
return UserData(
name=user.name,
email=user.email,
)
def process_items(items: list[Item | None]) -> list[ProcessedItem]: # Filter and narrow types valid_items = [item for item in items if item is not None] # valid_items is now list[Item] return [process(item) for item in valid_items]
Pattern 4: Generic Classes
Create type-safe reusable containers.
from typing import TypeVar, Generic
T = TypeVar("T") E = TypeVar("E", bound=Exception)
class Result(Generic[T, E]): """Represents either a success value or an error."""
def __init__(
self,
value: T | None = None,
error: E | None = None,
) -> None:
if (value is None) == (error is None):
raise ValueError("Exactly one of value or error must be set")
self._value = value
self._error = error
@property
def is_success(self) -> bool:
return self._error is None
@property
def is_failure(self) -> bool:
return self._error is not None
def unwrap(self) -> T:
"""Get value or raise the error."""
if self._error is not None:
raise self._error
return self._value # type: ignore[return-value]
def unwrap_or(self, default: T) -> T:
"""Get value or return default."""
if self._error is not None:
return default
return self._value # type: ignore[return-value]
Usage preserves types
def parse_config(path: str) -> Result[Config, ConfigError]: try: return Result(value=Config.from_file(path)) except ConfigError as e: return Result(error=e)
result = parse_config("config.yaml") if result.is_success: config = result.unwrap() # Type: Config
Advanced Patterns
Pattern 5: Generic Repository
Create type-safe data access patterns.
from typing import TypeVar, Generic from abc import ABC, abstractmethod
T = TypeVar("T") ID = TypeVar("ID")
class Repository(ABC, Generic[T, ID]): """Generic repository interface."""
@abstractmethod
async def get(self, id: ID) -> T | None:
"""Get entity by ID."""
...
@abstractmethod
async def save(self, entity: T) -> T:
"""Save and return entity."""
...
@abstractmethod
async def delete(self, id: ID) -> bool:
"""Delete entity, return True if existed."""
...
class UserRepository(Repository[User, str]): """Concrete repository for Users with string IDs."""
async def get(self, id: str) -> User | None:
row = await self._db.fetchrow(
"SELECT * FROM users WHERE id = $1", id
)
return User(**row) if row else None
async def save(self, entity: User) -> User:
...
async def delete(self, id: str) -> bool:
...
Pattern 6: TypeVar with Bounds
Restrict generic parameters to specific types.
from typing import TypeVar from pydantic import BaseModel
ModelT = TypeVar("ModelT", bound=BaseModel)
def validate_and_create(model_cls: type[ModelT], data: dict) -> ModelT: """Create a validated Pydantic model from dict.""" return model_cls.model_validate(data)
Works with any BaseModel subclass
class User(BaseModel): name: str email: str
user = validate_and_create(User, {"name": "Alice", "email": "a@b.com"})
user is typed as User
Type error: str is not a BaseModel subclass
result = validate_and_create(str, {"name": "Alice"}) # Error!
Pattern 7: Protocols for Structural Typing
Define interfaces without requiring inheritance.
from typing import Protocol, runtime_checkable
@runtime_checkable class Serializable(Protocol): """Any class that can be serialized to/from dict."""
def to_dict(self) -> dict:
...
@classmethod
def from_dict(cls, data: dict) -> "Serializable":
...
User satisfies Serializable without inheriting from it
class User: def init(self, id: str, name: str) -> None: self.id = id self.name = name
def to_dict(self) -> dict:
return {"id": self.id, "name": self.name}
@classmethod
def from_dict(cls, data: dict) -> "User":
return cls(id=data["id"], name=data["name"])
def serialize(obj: Serializable) -> str: """Works with any Serializable object.""" return json.dumps(obj.to_dict())
Works - User matches the protocol
serialize(User("1", "Alice"))
Runtime checking with @runtime_checkable
isinstance(User("1", "Alice"), Serializable) # True
Pattern 8: Common Protocol Patterns
Define reusable structural interfaces.
from typing import Protocol
class Closeable(Protocol): """Resource that can be closed.""" def close(self) -> None: ...
class AsyncCloseable(Protocol): """Async resource that can be closed.""" async def close(self) -> None: ...
class Readable(Protocol): """Object that can be read from.""" def read(self, n: int = -1) -> bytes: ...
class HasId(Protocol): """Object with an ID property.""" @property def id(self) -> str: ...
class Comparable(Protocol): """Object that supports comparison.""" def lt(self, other: "Comparable") -> bool: ... def le(self, other: "Comparable") -> bool: ...
Pattern 9: Type Aliases
Create meaningful type names.
Note: The type statement was introduced in Python 3.10 for simple aliases. Generic type statements require Python 3.12+.
Python 3.10+ type statement for simple aliases
type UserId = str type UserDict = dict[str, Any]
Python 3.12+ type statement with generics
type Handler[T] = Callable[[Request], T] type AsyncHandler[T] = Callable[[Request], Awaitable[T]]
Python 3.9-3.11 style (needed for broader compatibility)
from typing import TypeAlias from collections.abc import Callable, Awaitable
UserId: TypeAlias = str Handler: TypeAlias = Callable[[Request], Response]
Usage
def register_handler(path: str, handler: Handler[Response]) -> None: ...
Pattern 10: Callable Types
Type function parameters and callbacks.
from collections.abc import Callable, Awaitable
Sync callback
ProgressCallback = Callable[[int, int], None] # (current, total)
Async callback
AsyncHandler = Callable[[Request], Awaitable[Response]]
With named parameters (using Protocol)
class OnProgress(Protocol): def call( self, current: int, total: int, *, message: str = "", ) -> None: ...
def process_items( items: list[Item], on_progress: ProgressCallback | None = None, ) -> list[Result]: for i, item in enumerate(items): if on_progress: on_progress(i, len(items)) ...
Configuration
Strict Mode Checklist
For mypy --strict compliance:
pyproject.toml
[tool.mypy] python_version = "3.12" strict = true warn_return_any = true warn_unused_ignores = true disallow_untyped_defs = true disallow_incomplete_defs = true no_implicit_optional = true
Incremental adoption goals:
-
All function parameters annotated
-
All return types annotated
-
Class attributes annotated
-
Minimize Any usage (acceptable for truly dynamic data)
-
Generic collections use type parameters (list[str] not list )
For existing codebases, enable strict mode per-module using # mypy: strict or configure per-module overrides in pyproject.toml .
Best Practices Summary
-
Annotate all public APIs - Functions, methods, class attributes
-
Use T | None
-
Modern union syntax over Optional[T]
-
Run strict type checking - mypy --strict in CI
-
Use generics - Preserve type info in reusable code
-
Define protocols - Structural typing for interfaces
-
Narrow types - Use guards to help the type checker
-
Bound type vars - Restrict generics to meaningful types
-
Create type aliases - Meaningful names for complex types
-
Minimize Any
-
Use specific types or generics. Any is acceptable for truly dynamic data or when interfacing with untyped third-party code
-
Document with types - Types are enforceable documentation