| name | python-type-safety |
| description | Modern Python type safety with type hints, generics, protocols, and strict type checking using ty and ruff. Use when adding type annotations, implementing generic classes, defining structural interfaces, configuring ty/ruff, or writing type-safe Python 3.13+/3.14+ code. Triggers on mentions of type hints, typing, TypeVar, Protocol, overload, TypeIs, Pydantic models, ty check, ruff type rules, or any static type analysis in Python. |
Python Type Safety (3.13+ / 3.14+)
Modern Python type safety using the Astral toolchain: ty for type checking and language server, ruff for linting (including annotation enforcement), and uv for package management. All examples target Python 3.13+ with 3.14 deferred annotations as the default.
Toolchain
| Tool | Role | Install |
|---|
| ty | Type checker + LSP (10-100x faster than mypy) | uv tool install ty@latest |
| ruff | Linter + formatter (replaces flake8, black, isort) | uv tool install ruff |
| uv | Package manager + env management | astral.sh/uv |
ty is the type checker from Astral (the creators of ruff and uv). It is written in Rust, provides a full language server (LSP) with code navigation, completions, and inlay hints, and supports advanced features like intersection types, reachability analysis, and gradual typing guarantees.
Key Python Version Features
Python 3.12
- PEP 695 — Type parameter syntax:
def first[T](items: list[T]) -> T:
- PEP 695 —
type statement for aliases: type Point = tuple[float, float]
Python 3.13
- PEP 696 — TypeVar/ParamSpec/TypeVarTuple defaults:
class Box[T = int]:
- PEP 742 —
TypeIs for proper bidirectional type narrowing
- PEP 705 —
ReadOnly for TypedDict items
- PEP 702 —
warnings.deprecated() decorator with type system support
Python 3.14
- PEP 649/749 — Deferred evaluation of annotations (no more
from __future__ import annotations, no more forward reference strings)
- PEP 758 —
except and except* expressions may omit brackets
Fundamental Patterns
Pattern 1: Annotate All Public Signatures
Every public function, method, and class should have type annotations. Use native generics everywhere (no typing.List, typing.Dict, etc. — those are ancient history).
from pydantic import BaseModel
class User(BaseModel):
id: str
name: str
email: str | None = None
class UserRepository:
def __init__(self, db: Database) -> None:
self._db = db
async def find_by_id(self, user_id: str) -> User | None:
...
async def find_by_email(self, email: str) -> User | None:
...
async def save(self, user: User) -> User:
...
Pattern 2: Type Narrowing with TypeIs (3.13+)
TypeIs provides proper bidirectional narrowing — when the guard returns False, the type checker narrows the else branch too (unlike the older TypeGuard).
from typing import TypeIs
def is_string(x: int | str) -> TypeIs[str]:
return isinstance(x, str)
def process(value: int | str) -> str:
if is_string(value):
return value.upper()
else:
return str(value * 2)
def is_valid_user(obj: dict | User) -> TypeIs[User]:
return isinstance(obj, User)
Use TypeIs over TypeGuard in all new code. TypeGuard only narrows the true branch; TypeIs narrows both.
Pattern 3: Modern Generic Classes (3.12+ Syntax)
Use the built-in type parameter syntax — no TypeVar boilerplate needed.
from pydantic import BaseModel
class Result[T, E: Exception]:
"""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
def unwrap(self) -> T:
if self._error is not None:
raise self._error
return self._value
def unwrap_or(self, default: T) -> T:
if self._error is not None:
return default
return self._value
class ConfigError(Exception): ...
class Config(BaseModel):
host: str
port: int
def parse_config(path: str) -> Result[Config, ConfigError]:
try:
return Result(value=Config.model_validate_json(open(path).read()))
except Exception as e:
return Result(error=ConfigError(str(e)))
Pattern 4: TypeVar Defaults (3.13+)
Generic parameters can now have defaults, reducing boilerplate for common cases.
class Response[T = dict]:
def __init__(self, data: T, status: int = 200) -> None:
self.data = data
self.status = status
r1 = Response({"ok": True})
r2 = Response(["a", "b"], status=201)
class Container[T = int]:
def __init__(self, value: T) -> None:
self.value = value
c = Container(42)
Pattern 5: Protocols for Structural Typing
Define interfaces without inheritance — duck typing with full type safety.
from typing import Protocol, runtime_checkable
@runtime_checkable
class Serializable(Protocol):
def to_dict(self) -> dict: ...
@classmethod
def from_dict(cls, data: dict) -> "Serializable": ...
class Closeable(Protocol):
def close(self) -> None: ...
class AsyncCloseable(Protocol):
async def close(self) -> None: ...
class HasId(Protocol):
@property
def id(self) -> str: ...
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:
import json
return json.dumps(obj.to_dict())
serialize(User("1", "Alice"))
isinstance(User("1", "Alice"), Serializable)
Pattern 6: Deferred Annotations (3.14+)
In Python 3.14, annotations are evaluated lazily. Forward references just work — no string quoting, no from __future__ import annotations.
class TreeNode[T]:
value: T
children: list[TreeNode[T]]
def __init__(self, value: T) -> None:
self.value = value
self.children = []
def add_child(self, value: T) -> TreeNode[T]:
child = TreeNode(value)
self.children.append(child)
return child
class Order:
customer: Customer
total: float
class Customer:
orders: list[Order]
name: str
The new annotationlib module provides tools for introspecting deferred annotations when needed at runtime (VALUE, FORWARDREF, STRING formats).
Advanced Patterns
Pattern 7: Discriminated Unions
Use Literal types for tagged unions with exhaustive pattern matching.
from typing import Literal
from pydantic import BaseModel
class EmailNotification(BaseModel):
type: Literal["email"] = "email"
to: str
subject: str
body: str
class SMSNotification(BaseModel):
type: Literal["sms"] = "sms"
to: str
message: str
class PushNotification(BaseModel):
type: Literal["push"] = "push"
device_token: str
title: str
body: str
type Notification = EmailNotification | SMSNotification | PushNotification
async def send_notification(notification: Notification) -> bool:
match notification.type:
case "email":
return await send_email(notification.to, notification.subject, notification.body)
case "sms":
return await send_sms(notification.to, notification.message)
case "push":
return await send_push(notification.device_token, notification.title, notification.body)
Pattern 8: ParamSpec for Type-Safe Decorators
Preserve function signatures through decorators.
from typing import ParamSpec, Callable, Awaitable
from functools import wraps
import time
type P = ParamSpec
from typing import ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def timing(func: Callable[P, R]) -> Callable[P, R]:
@wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.4f}s")
return result
return wrapper
def retry(
max_attempts: int = 3,
exceptions: tuple[type[Exception], ...] = (Exception,),
) -> Callable[[Callable[P, R]], Callable[P, R]]:
def decorator(func: Callable[P, R]) -> Callable[P, R]:
@wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
last_exception: Exception | None = None
for _ in range(max_attempts):
try:
return func(*args, **kwargs)
except exceptions as e:
last_exception = e
raise last_exception or RuntimeError("Retry failed")
return wrapper
return decorator
@retry(max_attempts=3, exceptions=(ValueError, IOError))
@timing
def fetch_data(url: str, timeout: int = 30) -> dict:
...
Note: Python 3.12 introduced def f[**P, R](...) syntax for ParamSpec in function signatures, but ParamSpec and TypeVar are still needed for module-level decorator definitions.
Pattern 9: Conditional Types with Overloads
Return different types based on argument values.
from typing import overload, Literal
@overload
def fetch(url: str, *, as_json: Literal[True]) -> dict: ...
@overload
def fetch(url: str, *, as_json: Literal[False] = False) -> str: ...
def fetch(url: str, *, as_json: bool = False) -> dict | str:
import requests
response = requests.get(url)
if as_json:
return response.json()
return response.text
data: dict = fetch("https://api.example.com", as_json=True)
text: str = fetch("https://api.example.com")
Pattern 10: Generic Repository with Pydantic
Type-safe data access using Pydantic models.
from typing import Protocol
from pydantic import BaseModel
from abc import abstractmethod
class Repository[T: BaseModel, ID](Protocol):
async def get(self, id: ID) -> T | None: ...
async def get_all(self) -> list[T]: ...
async def save(self, entity: T) -> T: ...
async def delete(self, id: ID) -> bool: ...
class User(BaseModel):
id: str
name: str
email: str
class SQLRepository[T: BaseModel]:
def __init__(self, session: AsyncSession, model_cls: type[T]) -> None:
self._session = session
self._model_cls = model_cls
async def get(self, id: str) -> T | None:
...
async def save(self, entity: T) -> T:
data = entity.model_dump()
...
return entity
class UserRepository(SQLRepository[User]):
async def find_by_email(self, email: str) -> User | None:
...
Pattern 11: Builder Pattern with Self
Self (3.11+) enables proper return typing for method chaining in inheritance hierarchies.
from typing import Self
from pydantic import BaseModel
class QueryBuilder[T]:
def __init__(self, model: type[T]) -> None:
self._model = model
self._filters: list[str] = []
self._order_by: str | None = None
self._limit: int | None = None
def filter(self, condition: str) -> Self:
self._filters.append(condition)
return self
def order_by(self, field: str) -> Self:
self._order_by = field
return self
def limit(self, n: int) -> Self:
self._limit = n
return self
def build(self) -> str:
query = f"SELECT * FROM {self._model.__name__}"
if self._filters:
query += " WHERE " + " AND ".join(self._filters)
if self._order_by:
query += f" ORDER BY {self._order_by}"
if self._limit:
query += f" LIMIT {self._limit}"
return query
Pattern 12: ReadOnly TypedDict (3.13+)
Mark specific TypedDict fields as immutable for type checkers.
from typing import TypedDict, ReadOnly, Required, NotRequired
class Config(TypedDict):
host: ReadOnly[str]
port: ReadOnly[int]
debug: NotRequired[bool]
class APIResponse(TypedDict):
status: ReadOnly[Required[int]]
data: dict
error: NotRequired[str]
def update_config(config: Config) -> None:
config["debug"] = True
config["host"] = "new.host"
Pattern 13: Typed Event System
Type-safe event bus using generics.
import asyncio
from typing import Callable, Awaitable
from pydantic import BaseModel
class Event(BaseModel):
timestamp: float
class UserCreatedEvent(Event):
user_id: int
email: str
class OrderPlacedEvent(Event):
order_id: int
user_id: int
total: float
class TypedEventBus:
def __init__(self) -> None:
self._handlers: dict[type, list[Callable]] = {}
def subscribe[E: Event](
self,
event_type: type[E],
handler: Callable[[E], Awaitable[None]],
) -> None:
if event_type not in self._handlers:
self._handlers[event_type] = []
self._handlers[event_type].append(handler)
async def publish[E: Event](self, event: E) -> None:
handlers = self._handlers.get(type(event), [])
await asyncio.gather(*[h(event) for h in handlers])
Pattern 14: Annotated for Rich Metadata
Combine type information with validation constraints (works great with Pydantic).
from typing import Annotated
from pydantic import BaseModel, Field
type UserId = Annotated[int, Field(gt=0)]
type Email = Annotated[str, Field(pattern=r"^[\w.-]+@[\w.-]+\.\w+$")]
type Age = Annotated[int, Field(ge=0, le=150)]
type NonEmpty = Annotated[str, Field(min_length=1)]
class CreateUserRequest(BaseModel):
name: NonEmpty
email: Email
age: Age
class UserResponse(BaseModel):
id: UserId
name: str
email: str
Pattern 15: Callable Types with Protocol
When you need named parameters on callable types, use Protocol.
from typing import Protocol
from collections.abc import Callable, Awaitable
type ProgressCallback = Callable[[int, int], None]
type AsyncHandler = Callable[..., Awaitable[dict]]
class OnProgress(Protocol):
def __call__(
self,
current: int,
total: int,
*,
message: str = "",
) -> None: ...
def process_items(
items: list[str],
on_progress: OnProgress | None = None,
) -> list[str]:
results = []
for i, item in enumerate(items):
if on_progress:
on_progress(i, len(items), message=f"Processing {item}")
results.append(item.upper())
return results
Pattern 16: Recursive Types
from typing import TypeAlias
from collections.abc import Iterator
type JsonPrimitive = str | int | float | bool | None
type JsonArray = list[JsonValue]
type JsonObject = dict[str, JsonValue]
type JsonValue = JsonPrimitive | JsonArray | JsonObject
def parse_json(text: str) -> JsonValue:
import json
return json.loads(text)
class TreeNode[T]:
value: T
children: list[TreeNode[T]]
def __init__(self, value: T) -> None:
self.value = value
self.children = []
def traverse(self) -> Iterator[T]:
yield self.value
for child in self.children:
yield from child.traverse()
Pattern 17: Deprecation Markers (3.13+)
Mark deprecated APIs so type checkers emit warnings.
import warnings
@warnings.deprecated("Use process_v2() instead")
def process(data: str) -> str:
return process_v2(data)
def process_v2(data: str) -> str:
return data.upper()
result = process("hello")
Configuration
ty Configuration
[tool.ty.environment]
python-version = "3.14"
[tool.ty.rules]
unresolved-import = "error"
invalid-assignment = "error"
invalid-argument-type = "error"
invalid-return-type = "error"
unused-ignore-comment = "warn"
possibly-missing-import = "warn"
deprecated = "warn"
division-by-zero = "warn"
[tool.ty.analysis]
allowed-unresolved-imports = ["_typeshed.**"]
[[tool.ty.overrides]]
include = ["src/legacy/**"]
rules = { unresolved-attribute = "ignore" }
ruff Configuration (Annotation Enforcement)
Use ruff's ANN rules (flake8-annotations) to enforce annotation coverage, and UP rules (pyupgrade) to modernize syntax.
[tool.ruff]
target-version = "py313"
line-length = 120
[tool.ruff.lint]
select = [
"E",
"F",
"I",
"UP",
"ANN",
"B",
"SIM",
"TCH",
"RUF",
]
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["ANN"]
[tool.ruff.lint.flake8-type-checking]
runtime-evaluated-base-classes = ["pydantic.BaseModel"]
Running in CI
ty check src/
ty check --watch
ruff check src/
ruff format src/
uv run ty check src/ && uv run ruff check src/
Inline Suppression
result = greet(42)
x = 1
Best Practices
- Use Pydantic for data models — Never plain dataclasses. Pydantic gives you runtime validation, serialization, and schema generation alongside type safety.
- Annotate all public APIs — Functions, methods, class attributes. Let ruff's
ANN rules enforce this.
- Use
T | None — Modern union syntax everywhere. Never Optional[T] or Union[X, Y].
- Use native generics —
list[str], dict[str, int], tuple[int, ...]. Never import from typing.
- Use 3.12+ type parameter syntax —
def f[T](x: T) -> T: and class C[T]: instead of TypeVar.
- Use
TypeIs over TypeGuard — Proper bidirectional narrowing (3.13+).
- Use
type statement for aliases — type Point = tuple[float, float] instead of TypeAlias.
- Prefer Protocols over ABCs — Structural typing is more flexible than nominal typing.
- Use abstract collection types for parameters —
Iterable[str], Sequence[str], Mapping[str, int] from collections.abc.
- Minimize
Any — Use specific types or generics. Any is acceptable for truly dynamic data or untyped third-party code.
- Use
Annotated with Pydantic Field — Rich type aliases that carry validation constraints.
- Run ty + ruff in CI — ty for type checking, ruff for annotation enforcement and modernization.