python-type-safety ยท diff
git:20260326.344a912 to git:20260522.be57c0b
2 added, 234 removed. Audit A to A.
---
name: python-type-safety
description: Python type safety with type hints, generics, protocols, and strict type checking. Use when adding type annotations, implementing generic classes, defining structural interfaces, or configuring mypy/pyright.
---
# 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
### 1. Type Annotations
Declare expected types for function parameters, return values, and variables.
### 2. Generics
Write reusable code that preserves type information across different types.
### 3. Protocols
Define structural interfaces without inheritance (duck typing with type safety).
### 4. Type Narrowing
Use guards and conditionals to narrow types within code blocks.
## Quick Start
```python
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.
```python
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.
```python
# 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.
```python
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.
```python
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.
-
- ```python
- 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.
-
- ```python
- 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.
-
- ```python
- 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.
-
- ```python
- 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 Alias = ...` statement syntax (PEP 695) was introduced in **Python 3.12**, not 3.10. For projects targeting earlier versions (including 3.10/3.11), use the `TypeAlias` annotation (PEP 613, available since Python 3.10).
-
- ```python
- # Python 3.12+ type statement (PEP 695)
- type UserId = str
- type UserDict = dict[str, Any]
-
- # Python 3.12+ type statement with generics (PEP 695)
- type Handler[T] = Callable[[Request], T]
- type AsyncHandler[T] = Callable[[Request], Awaitable[T]]
- ```
-
- ```python
- # Python 3.10-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]
- ```
-
- ```python
- # Usage
- def register_handler(path: str, handler: Handler[Response]) -> None:
- ...
- ```
-
- ### Pattern 10: Callable Types
-
- Type function parameters and callbacks.
-
- ```python
- 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:
-
- ```toml
- # 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`)
+ ## Detailed worked examples and patterns
- For existing codebases, enable strict mode per-module using `# mypy: strict` or configure per-module overrides in `pyproject.toml`.
+ Detailed sections (starting with `## Advanced Patterns`) live in `references/details.md`. Read that file when the navigation summary above is insufficient.
## Best Practices Summary
1. **Annotate all public APIs** - Functions, methods, class attributes
2. **Use `T | None`** - Modern union syntax over `Optional[T]`
3. **Run strict type checking** - `mypy --strict` in CI
4. **Use generics** - Preserve type info in reusable code
5. **Define protocols** - Structural typing for interfaces
6. **Narrow types** - Use guards to help the type checker
7. **Bound type vars** - Restrict generics to meaningful types
8. **Create type aliases** - Meaningful names for complex types
9. **Minimize `Any`** - Use specific types or generics. `Any` is acceptable for truly dynamic data or when interfacing with untyped third-party code
10. **Document with types** - Types are enforceable documentation