skills/wshobson/agents/python-type-safety

# python-type-safety

SKILL.md

# 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

    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

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

Weekly Installs
1.2K
Repository
wshobson/agents
First Seen
Jan 30, 2026
Security Audits
Gen Agent Trust Hub Pass
Socket Pass
Snyk Pass
Installed on
claude-code 868
opencode 803
gemini-cli 765
codex 724
cursor 585
github-copilot 575