python-type-safety

star 14

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.

AI-Riksarkivet By AI-Riksarkivet schedule Updated 3/30/2026

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 695type statement for aliases: type Point = tuple[float, float]

Python 3.13

  • PEP 696 — TypeVar/ParamSpec/TypeVarTuple defaults: class Box[T = int]:
  • PEP 742TypeIs for proper bidirectional type narrowing
  • PEP 705ReadOnly for TypedDict items
  • PEP 702warnings.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 758except 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()       # value: str
    else:
        return str(value * 2)      # value: int (properly narrowed)


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  # type: ignore[return-value]

    def unwrap_or(self, default: T) -> T:
        if self._error is not None:
            return default
        return self._value  # type: ignore[return-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


# Uses default: Response[dict]
r1 = Response({"ok": True})

# Explicit: Response[list[str]]
r2 = Response(["a", "b"], status=201)


class Container[T = int]:
    def __init__(self, value: T) -> None:
        self.value = value

# Container[int] by default
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: ...


# Any class with matching methods satisfies the protocol — no inheritance needed
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"))  # Works — User matches Protocol
isinstance(User("1", "Alice"), Serializable)  # True at runtime

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.

# This just works in 3.14+ — no special imports needed
class TreeNode[T]:
    value: T
    children: list[TreeNode[T]]  # Forward reference, no quotes needed

    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


# Mutually recursive types — also just work
class Order:
    customer: Customer  # Forward reference resolved lazily
    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  # Can't use type statement for ParamSpec yet

# Use the traditional form for 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


# Type checker knows the exact return type
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]          # Cannot be modified after creation
    port: ReadOnly[int]
    debug: NotRequired[bool]     # Optional, but mutable if present


class APIResponse(TypedDict):
    status: ReadOnly[Required[int]]
    data: dict                   # Mutable
    error: NotRequired[str]


def update_config(config: Config) -> None:
    config["debug"] = True        # OK — mutable field
    config["host"] = "new.host"   # Type error — ReadOnly

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


# Simple callables — use Callable
type ProgressCallback = Callable[[int, int], None]
type AsyncHandler = Callable[..., Awaitable[dict]]

# Named parameters — use Protocol
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


# JSON type (recursive)
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)


# Tree structure
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()


# Type checker will warn when calling process()
result = process("hello")  # Deprecated: Use process_v2() instead

Configuration

ty Configuration

# pyproject.toml
[tool.ty.environment]
python-version = "3.14"

[tool.ty.rules]
# Promote common issues to errors
unresolved-import = "error"
invalid-assignment = "error"
invalid-argument-type = "error"
invalid-return-type = "error"

# Useful warnings
unused-ignore-comment = "warn"
possibly-missing-import = "warn"
deprecated = "warn"
division-by-zero = "warn"

[tool.ty.analysis]
# Suppress known unresolvable third-party imports
allowed-unresolved-imports = ["_typeshed.**"]

# Per-path overrides for legacy code
[[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.

# pyproject.toml
[tool.ruff]
target-version = "py313"
line-length = 120

[tool.ruff.lint]
select = [
    "E",    # pycodestyle
    "F",    # pyflakes
    "I",    # isort
    "UP",   # pyupgrade — modernize syntax (e.g. remove old typing imports)
    "ANN",  # flake8-annotations — enforce type annotations
    "B",    # bugbear
    "SIM",  # simplify
    "TCH",  # flake8-type-checking — move type-only imports behind TYPE_CHECKING
    "RUF",  # ruff-specific rules
]

[tool.ruff.lint.per-file-ignores]
"tests/**" = ["ANN"]  # Don't require annotations in tests

[tool.ruff.lint.flake8-type-checking]
runtime-evaluated-base-classes = ["pydantic.BaseModel"]

Running in CI

# Type check
ty check src/

# Type check in watch mode (development)
ty check --watch

# Lint + format
ruff check src/
ruff format src/

# All three in CI
uv run ty check src/ && uv run ruff check src/

Inline Suppression

# Suppress a specific ty diagnostic
result = greet(42)  # ty: ignore[invalid-argument-type]

# Suppress ruff
x = 1  # noqa: ANN001

Best Practices

  1. Use Pydantic for data models — Never plain dataclasses. Pydantic gives you runtime validation, serialization, and schema generation alongside type safety.
  2. Annotate all public APIs — Functions, methods, class attributes. Let ruff's ANN rules enforce this.
  3. Use T | None — Modern union syntax everywhere. Never Optional[T] or Union[X, Y].
  4. Use native genericslist[str], dict[str, int], tuple[int, ...]. Never import from typing.
  5. Use 3.12+ type parameter syntaxdef f[T](x: T) -> T: and class C[T]: instead of TypeVar.
  6. Use TypeIs over TypeGuard — Proper bidirectional narrowing (3.13+).
  7. Use type statement for aliasestype Point = tuple[float, float] instead of TypeAlias.
  8. Prefer Protocols over ABCs — Structural typing is more flexible than nominal typing.
  9. Use abstract collection types for parametersIterable[str], Sequence[str], Mapping[str, int] from collections.abc.
  10. Minimize Any — Use specific types or generics. Any is acceptable for truly dynamic data or untyped third-party code.
  11. Use Annotated with Pydantic Field — Rich type aliases that carry validation constraints.
  12. Run ty + ruff in CI — ty for type checking, ruff for annotation enforcement and modernization.
Install via CLI
npx skills add https://github.com/AI-Riksarkivet/ra-mcp --skill python-type-safety
Repository Details
star Stars 14
call_split Forks 2
navigation Branch main
article Path SKILL.md
More from Creator
AI-Riksarkivet
AI-Riksarkivet Explore all skills →