Claude Code Plugins

Community-maintained marketplace

Feedback

python-type-hints-guide

@clostaunau/holiday-card
0
0

Comprehensive reference guide for Python type hints, static type checking with mypy, modern type annotation patterns (PEP 484, 585, 604, 612, 613), and type hint best practices for Python 3.9+. Use during code reviews to ensure proper type annotation usage, evaluate mypy configuration, and identify type hint anti-patterns.

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

name python-type-hints-guide
description Comprehensive reference guide for Python type hints, static type checking with mypy, modern type annotation patterns (PEP 484, 585, 604, 612, 613), and type hint best practices for Python 3.9+. Use during code reviews to ensure proper type annotation usage, evaluate mypy configuration, and identify type hint anti-patterns.
allowed-tools Read, Grep, Glob

Python Type Hints Guide

Purpose

This skill provides comprehensive guidance on Python type hints and static type checking for Python 3.9+ codebases. It serves as a reference during code reviews to ensure proper type annotation usage, evaluate type hint quality, and promote best practices for gradual typing adoption.

This skill should be referenced by the uncle-duke-python agent when:

  • Reviewing Python code for type hint usage
  • Evaluating type annotation quality and consistency
  • Identifying type hint anti-patterns
  • Recommending mypy configuration improvements
  • Guiding gradual typing adoption strategies

Context

Type hints (introduced in PEP 484) enable static type checking in Python while maintaining the language's dynamic nature. Modern Python (3.9+) has significantly improved type hint syntax with built-in generic types and the union operator. Proper type hints improve:

  • Code documentation: Self-documenting function signatures
  • IDE support: Better autocomplete and refactoring
  • Bug detection: Catch type errors before runtime
  • Maintainability: Easier to understand code contracts
  • Refactoring safety: Catch breaking changes early

This guide focuses on modern Python (3.9+) patterns and assumes familiarity with basic Python syntax.

Prerequisites

  • Python 3.9 or later (for built-in generic types)
  • Python 3.10+ recommended (for union operator |)
  • mypy installed for static type checking: pip install mypy
  • Understanding of Python's dynamic typing system

Type Hints Basics (PEP 484)

Basic Types

Use built-in type names directly for simple types:

def greet(name: str) -> str:
    return f"Hello, {name}!"

def add(x: int, y: int) -> int:
    return x + y

def calculate_average(scores: list[float]) -> float:
    return sum(scores) / len(scores)

def is_valid(flag: bool) -> bool:
    return not flag

None Type

Use None for functions that don't return a value:

def log_message(message: str) -> None:
    print(f"[LOG] {message}")
    # No return statement or explicit return None

Optional Types

Use Optional[T] or T | None (Python 3.10+) for values that can be None:

from typing import Optional

# Python 3.9 style
def find_user(user_id: int) -> Optional[dict]:
    # May return dict or None
    return None

# Python 3.10+ style (preferred)
def find_user_modern(user_id: int) -> dict | None:
    # May return dict or None
    return None

# With default None parameter
def greet(name: str, title: str | None = None) -> str:
    if title:
        return f"Hello, {title} {name}!"
    return f"Hello, {name}!"

Collection Types (Python 3.9+)

Python 3.9+ allows using built-in collection types directly (lowercase):

# Modern Python 3.9+ (preferred)
def process_names(names: list[str]) -> dict[str, int]:
    return {name: len(name) for name in names}

def unique_items(items: set[int]) -> list[int]:
    return sorted(items)

def get_coordinates() -> tuple[float, float]:
    return (42.0, -71.0)

# Variable-length tuple (homogeneous)
def process_values(values: tuple[int, ...]) -> int:
    return sum(values)

# Nested collections
def process_matrix(matrix: list[list[float]]) -> float:
    return sum(sum(row) for row in matrix)

Legacy Python 3.8 and below (avoid in modern code):

from typing import List, Dict, Set, Tuple

def process_names(names: List[str]) -> Dict[str, int]:
    return {name: len(name) for name in names}

Union Types

Use Union[T, U] or T | U (Python 3.10+) for multiple possible types:

from typing import Union

# Python 3.9 style
def process_id(user_id: Union[int, str]) -> str:
    return str(user_id)

# Python 3.10+ style (preferred)
def process_id_modern(user_id: int | str) -> str:
    return str(user_id)

# Multiple types
def parse_value(value: int | float | str | None) -> float:
    if value is None:
        return 0.0
    return float(value)

Any Type

Use Any when type cannot be determined or for gradual typing:

from typing import Any

# Accept any type (escape hatch)
def legacy_function(data: Any) -> Any:
    # Used when migrating untyped code
    return data

# Dict with any values
def process_config(config: dict[str, Any]) -> None:
    # Config can have values of any type
    pass

Warning: Overusing Any defeats the purpose of type hints. Use sparingly and document why.

Advanced Types

Generic Types (TypeVar, Generic)

Create reusable generic functions and classes:

from typing import TypeVar, Generic

# Type variable for generic functions
T = TypeVar('T')

def first_item(items: list[T]) -> T | None:
    return items[0] if items else None

# Usage preserves type information
names: list[str] = ["Alice", "Bob"]
first_name: str | None = first_item(names)  # Type checker knows this is str | None

numbers: list[int] = [1, 2, 3]
first_number: int | None = first_item(numbers)  # Type checker knows this is int | None

# Generic class
class Stack(Generic[T]):
    def __init__(self) -> None:
        self._items: list[T] = []

    def push(self, item: T) -> None:
        self._items.append(item)

    def pop(self) -> T | None:
        return self._items.pop() if self._items else None

# Usage
string_stack: Stack[str] = Stack()
string_stack.push("hello")  # OK
string_stack.push(42)  # Type error!

# Bounded type variable (constrains to subclasses)
from numbers import Number
NumT = TypeVar('NumT', bound=Number)

def add_numbers(x: NumT, y: NumT) -> NumT:
    return x + y  # type: ignore[return-value]

Protocol Classes (Structural Subtyping - PEP 544)

Define interfaces based on structure (duck typing with type checking):

from typing import Protocol

class Drawable(Protocol):
    def draw(self) -> None:
        ...

class Circle:
    def draw(self) -> None:
        print("Drawing circle")

class Square:
    def draw(self) -> None:
        print("Drawing square")

# Both Circle and Square satisfy Drawable protocol
# without explicit inheritance
def render(shape: Drawable) -> None:
    shape.draw()

render(Circle())  # OK
render(Square())  # OK

# Protocol with properties
class Sized(Protocol):
    @property
    def size(self) -> int:
        ...

# Real-world example: file-like objects
class SupportsRead(Protocol):
    def read(self, size: int = -1) -> str:
        ...

def process_file(file: SupportsRead) -> str:
    return file.read()

Literal Types (PEP 586)

Specify exact literal values allowed:

from typing import Literal

def set_log_level(level: Literal["DEBUG", "INFO", "WARNING", "ERROR"]) -> None:
    print(f"Log level set to {level}")

set_log_level("DEBUG")  # OK
set_log_level("TRACE")  # Type error!

# Multiple literals
def open_file(path: str, mode: Literal["r", "w", "a", "rb", "wb"]) -> None:
    pass

# Literal booleans (rarely needed)
def process(flag: Literal[True]) -> None:
    # Only accepts True, not False
    pass

# Combining with Union
Mode = Literal["read", "write", "append"]
def process_mode(mode: Mode | None = None) -> None:
    pass

TypedDict (PEP 589)

Define dictionaries with specific key-value type requirements:

from typing import TypedDict, NotRequired

# Basic TypedDict
class User(TypedDict):
    name: str
    age: int
    email: str

def create_user(user: User) -> None:
    print(f"Creating user: {user['name']}")

# Usage
user: User = {"name": "Alice", "age": 30, "email": "alice@example.com"}
create_user(user)  # OK

# Missing required key
bad_user: User = {"name": "Bob", "age": 25}  # Type error! Missing 'email'

# Optional keys (Python 3.11+)
class UserOptional(TypedDict):
    name: str
    age: int
    email: NotRequired[str]  # Optional key

# For Python 3.9-3.10, use total=False
class PartialUser(TypedDict, total=False):
    email: str
    phone: str

class RequiredUser(PartialUser):
    name: str  # Required
    age: int   # Required

# Inheritance
class Employee(User):
    employee_id: int
    department: str

Final Types (PEP 591)

Indicate values that should not be reassigned or overridden:

from typing import Final, final

# Final variable (constant)
MAX_CONNECTIONS: Final[int] = 100

# Type error if reassigned
MAX_CONNECTIONS = 200  # Type error!

# Final class attribute
class Config:
    API_URL: Final[str] = "https://api.example.com"

# Final method (cannot be overridden)
class Base:
    @final
    def process(self) -> None:
        print("Processing")

class Derived(Base):
    def process(self) -> None:  # Type error! Cannot override @final method
        print("Custom processing")

# Final class (cannot be subclassed)
@final
class ImmutablePoint:
    def __init__(self, x: float, y: float) -> None:
        self.x = x
        self.y = y

class Point3D(ImmutablePoint):  # Type error! Cannot subclass @final class
    pass

NewType

Create distinct types for type safety:

from typing import NewType

# Create new types for domain concepts
UserId = NewType('UserId', int)
OrderId = NewType('OrderId', int)

def get_user(user_id: UserId) -> dict:
    return {"id": user_id, "name": "User"}

def get_order(order_id: OrderId) -> dict:
    return {"id": order_id, "status": "pending"}

# Usage
user_id = UserId(42)
order_id = OrderId(100)

get_user(user_id)  # OK
get_user(order_id)  # Type error! OrderId is not UserId
get_user(42)  # Type error! int is not UserId

# NewType is zero-cost at runtime (just returns the value)
# But provides type safety during static checking

Callable Types

Type hint for callable objects (functions, lambdas, callables):

from typing import Callable

# Basic callable: (param_types...) -> return_type
def apply_operation(x: int, operation: Callable[[int], int]) -> int:
    return operation(x)

# Usage
def double(n: int) -> int:
    return n * 2

result = apply_operation(5, double)  # OK
result = apply_operation(5, lambda x: x * 3)  # OK

# Multiple parameters
def apply_binary(
    x: int,
    y: int,
    operation: Callable[[int, int], int]
) -> int:
    return operation(x, y)

apply_binary(5, 3, lambda a, b: a + b)  # OK

# No parameters
def run_callback(callback: Callable[[], None]) -> None:
    callback()

# Variable arguments (use ... for flexibility)
def log_with_formatter(
    message: str,
    formatter: Callable[..., str]
) -> None:
    formatted = formatter(message)
    print(formatted)

# Callback type alias
Validator = Callable[[str], bool]

def validate_input(value: str, validator: Validator) -> bool:
    return validator(value)

Type Aliases

Create readable aliases for complex types:

from typing import TypeAlias

# Simple alias
Vector: TypeAlias = list[float]
Matrix: TypeAlias = list[Vector]

def scale_vector(vector: Vector, factor: float) -> Vector:
    return [x * factor for x in vector]

# Complex nested types
JSON: TypeAlias = dict[str, "JSON"] | list["JSON"] | str | int | float | bool | None

def parse_json(data: str) -> JSON:
    import json
    return json.loads(data)

# Union aliases
Numeric: TypeAlias = int | float
OptionalString: TypeAlias = str | None

# Callable alias
HandlerFunction: TypeAlias = Callable[[str, dict], None]

# Generic alias
from typing import TypeVar
T = TypeVar('T')
Result: TypeAlias = tuple[T, str | None]  # (value, error)

def safe_parse(value: str) -> Result[int]:
    try:
        return (int(value), None)
    except ValueError as e:
        return (0, str(e))

Function Annotations

Parameter Type Hints

# Basic parameters
def greet(name: str, age: int) -> str:
    return f"{name} is {age} years old"

# Default values with type hints
def greet_with_title(
    name: str,
    title: str = "Mr.",
    excited: bool = False
) -> str:
    greeting = f"{title} {name}"
    return greeting + "!" if excited else greeting

# Multiple types (union)
def process_id(user_id: int | str) -> str:
    return str(user_id)

*args and **kwargs Type Hints

# *args: variable positional arguments
def sum_numbers(*args: int) -> int:
    return sum(args)

sum_numbers(1, 2, 3, 4)  # OK
sum_numbers(1, 2, "3")  # Type error!

# **kwargs: variable keyword arguments
def configure(**kwargs: str) -> dict[str, str]:
    return kwargs

configure(host="localhost", port="8000")  # OK
configure(host="localhost", port=8000)  # Type error! port must be str

# Mixed args, kwargs
def complex_function(
    required: str,
    *args: int,
    **kwargs: bool
) -> None:
    pass

complex_function("test", 1, 2, 3, flag=True, debug=False)  # OK

# Different types for args/kwargs
from typing import Any

def flexible_function(
    *args: int | str,
    **kwargs: Any
) -> None:
    pass

Overload Decorator

Use @overload to provide multiple type signatures:

from typing import overload

# Overload signatures (not implemented)
@overload
def process(data: str) -> str: ...

@overload
def process(data: int) -> int: ...

@overload
def process(data: list[str]) -> list[str]: ...

# Actual implementation (must be compatible with all overloads)
def process(data: str | int | list[str]) -> str | int | list[str]:
    if isinstance(data, str):
        return data.upper()
    elif isinstance(data, int):
        return data * 2
    else:
        return [s.upper() for s in data]

# Type checker knows return type based on input
result1: str = process("hello")  # OK, knows it returns str
result2: int = process(42)  # OK, knows it returns int
result3: list[str] = process(["a", "b"])  # OK, knows it returns list[str]

# More complex overload example
@overload
def get_value(container: dict[str, int], key: str) -> int: ...

@overload
def get_value(container: list[int], key: int) -> int: ...

def get_value(
    container: dict[str, int] | list[int],
    key: str | int
) -> int:
    return container[key]  # type: ignore[index]

Class Type Hints

Instance Variables

class User:
    # Instance variable annotations (PEP 526)
    name: str
    age: int
    email: str | None

    def __init__(self, name: str, age: int, email: str | None = None) -> None:
        self.name = name
        self.age = age
        self.email = email

# Alternative: annotate in __init__
class Product:
    def __init__(self, name: str, price: float) -> None:
        self.name: str = name
        self.price: float = price
        self.stock: int = 0  # Type inferred from default value

Class Variables (ClassVar)

from typing import ClassVar

class Config:
    # Class variable (shared across all instances)
    api_url: ClassVar[str] = "https://api.example.com"
    max_retries: ClassVar[int] = 3

    # Instance variable
    session_id: str

    def __init__(self, session_id: str) -> None:
        self.session_id = session_id

# Class variables are accessed on the class
print(Config.api_url)  # OK

# Type checker distinguishes class vs instance variables
config = Config("abc123")
config.session_id  # OK (instance variable)
config.api_url  # OK but mypy may warn (accessing class var from instance)

Property Type Hints

class Circle:
    def __init__(self, radius: float) -> None:
        self._radius = radius

    @property
    def radius(self) -> float:
        return self._radius

    @radius.setter
    def radius(self, value: float) -> None:
        if value <= 0:
            raise ValueError("Radius must be positive")
        self._radius = value

    @property
    def area(self) -> float:
        """Read-only property"""
        import math
        return math.pi * self._radius ** 2

# Cached property
from functools import cached_property

class DataProcessor:
    def __init__(self, data: list[int]) -> None:
        self._data = data

    @cached_property
    def processed_data(self) -> list[int]:
        """Expensive computation, cached after first access"""
        return [x * 2 for x in self._data]

init Annotations

class DatabaseConnection:
    def __init__(
        self,
        host: str,
        port: int = 5432,
        username: str | None = None,
        password: str | None = None,
        *,  # Force keyword-only arguments after this
        timeout: float = 30.0,
        ssl: bool = True
    ) -> None:
        self.host = host
        self.port = port
        self.username = username
        self.password = password
        self.timeout = timeout
        self.ssl = ssl

# Usage
db = DatabaseConnection(
    "localhost",
    5432,
    timeout=60.0,  # Keyword-only
    ssl=False
)

mypy Configuration

Basic mypy Setup

Create mypy.ini or pyproject.toml for mypy configuration:

mypy.ini:

[mypy]
# Type checking strictness
python_version = 3.9
warn_return_any = True
warn_unused_configs = True
disallow_untyped_defs = True
disallow_any_unimported = False
no_implicit_optional = True
warn_redundant_casts = True
warn_unused_ignores = True
warn_no_return = True
check_untyped_defs = True
strict_equality = True

# Import discovery
namespace_packages = True
ignore_missing_imports = False

# Per-module options
[mypy-tests.*]
disallow_untyped_defs = False

[mypy-third_party_lib.*]
ignore_missing_imports = True

pyproject.toml:

[tool.mypy]
python_version = "3.9"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
check_untyped_defs = true
strict_equality = true

[[tool.mypy.overrides]]
module = "tests.*"
disallow_untyped_defs = false

[[tool.mypy.overrides]]
module = "third_party_lib.*"
ignore_missing_imports = true

Common mypy Flags

Strict mode (all checks enabled):

mypy --strict myfile.py

Common individual flags:

# Require type hints on all functions
mypy --disallow-untyped-defs myfile.py

# Warn about functions that return Any
mypy --warn-return-any myfile.py

# Disallow Any types
mypy --disallow-any-expr myfile.py  # Very strict!

# Show error codes
mypy --show-error-codes myfile.py

# Incremental mode (faster)
mypy --incremental myfile.py

# Specific Python version
mypy --python-version 3.9 myfile.py

Useful combinations:

# Recommended starting point
mypy --check-untyped-defs --warn-redundant-casts myfile.py

# Strict for new code
mypy --strict --allow-untyped-calls myfile.py

# CI/CD friendly (no incremental cache)
mypy --no-incremental --show-error-codes mypackage/

Ignoring Errors Appropriately

Use type ignore comments sparingly and document why:

# Ignore specific error on one line
result = legacy_function()  # type: ignore[no-untyped-call]

# Ignore all errors on one line (avoid!)
bad_code = something()  # type: ignore

# Ignore multiple specific errors
value = complex_operation()  # type: ignore[arg-type, return-value]

# Ignore for entire function (last resort)
def legacy_code() -> Any:  # type: ignore[no-untyped-def]
    pass

# Better: Fix the issue or use proper typing
def proper_code() -> dict[str, Any]:
    """Returns config dict with various value types."""
    return {"key": "value"}

When to use type: ignore:

  • Interfacing with untyped third-party libraries
  • Complex type narrowing that mypy can't infer
  • Known false positives (file a mypy issue!)
  • Temporary during gradual typing migration

When NOT to use type: ignore:

  • To avoid adding type hints
  • Because "mypy is wrong" (usually mypy is right!)
  • Without documenting the reason
  • Instead of fixing the actual type error

Type: ignore Comments Best Practices

# BAD: No error code
result = func()  # type: ignore

# GOOD: Specific error code with explanation
# mypy can't infer the narrowed type after this check
result = func()  # type: ignore[arg-type]  # Known false positive, see issue #123

# GOOD: Document workaround
# TODO: Remove once library adds type stubs
data = legacy_lib.get_data()  # type: ignore[no-untyped-call]

# BEST: Fix the issue instead
def properly_typed_function(x: int) -> str:
    return str(x)

Best Practices

1. Gradual Typing Strategy

Start with critical code paths, expand gradually:

# Phase 1: Public API functions
def public_api(user_id: int) -> dict[str, Any]:
    return _internal_function(user_id)

def _internal_function(user_id):  # Not typed yet
    return {"id": user_id}

# Phase 2: Expand to internal functions
def _internal_function_typed(user_id: int) -> dict[str, int]:
    return {"id": user_id}

# Phase 3: Strict typing everywhere
def fully_typed(user_id: int) -> User:
    return User(id=user_id, name="Unknown")

Recommended adoption order:

  1. Public API functions and methods
  2. Core business logic
  3. Internal utilities
  4. Tests (can be less strict)
  5. Scripts and one-off code (optional)

2. When to Use Type Hints

Always type hint:

  • Public APIs and library functions
  • Function signatures (parameters and return types)
  • Class attributes and properties
  • Complex data structures

Consider type hints:

  • Internal/private functions in large codebases
  • Helper functions with non-obvious signatures
  • Callback functions

Optional type hints:

  • Very simple, obvious functions
  • Scripts and notebooks
  • Prototype code
  • One-liners and lambdas
# Always type hint: Public API
def calculate_discount(price: float, discount_percent: float) -> float:
    """Calculate discounted price."""
    return price * (1 - discount_percent / 100)

# Optional: Very obvious helper
def _double(x):
    return x * 2

# Consider: Less obvious helper (recommended)
def _format_currency(amount: float, currency: str = "USD") -> str:
    return f"{amount:.2f} {currency}"

3. Type Hint Readability

Prioritize readability over exhaustive typing:

# BAD: Overly complex, hard to read
def process(
    data: dict[str, list[tuple[int, str, dict[str, list[int | str | None]]]]]
) -> list[tuple[str, int]]:
    pass

# GOOD: Use type aliases for readability
PersonData = dict[str, list[int | str | None]]
Record = tuple[int, str, PersonData]
DataDict = dict[str, list[Record]]
Result = list[tuple[str, int]]

def process_readable(data: DataDict) -> Result:
    pass

# BETTER: Use TypedDict for structured dicts
class PersonData(TypedDict):
    age: int
    name: str
    tags: list[str]

class Record(TypedDict):
    id: int
    type: str
    data: PersonData

def process_structured(data: dict[str, list[Record]]) -> Result:
    pass

4. Avoiding Over-Specification

Don't type hint more than necessary:

# BAD: Over-specified (too rigid)
def process_items(items: list[str]) -> list[str]:
    return [item.upper() for item in items]

# GOOD: Accept any iterable, return list (more flexible)
from collections.abc import Iterable

def process_items_flexible(items: Iterable[str]) -> list[str]:
    return [item.upper() for item in items]

# Now works with lists, tuples, sets, generators, etc.
process_items_flexible(["a", "b"])  # OK
process_items_flexible(("a", "b"))  # OK
process_items_flexible(x for x in ["a", "b"])  # OK

# BAD: Forces specific dict implementation
def merge(d1: dict[str, int], d2: dict[str, int]) -> dict[str, int]:
    return {**d1, **d2}

# GOOD: Accept any mapping
from collections.abc import Mapping

def merge_flexible(
    d1: Mapping[str, int],
    d2: Mapping[str, int]
) -> dict[str, int]:
    return {**d1, **d2}

Use abstract types from collections.abc:

  • Iterable[T] instead of list[T] for inputs
  • Sequence[T] when you need indexing/length
  • Mapping[K, V] instead of dict[K, V] for inputs
  • MutableMapping[K, V] when you need to modify
  • Return concrete types like list, dict

5. Using Protocol for Duck Typing

Prefer Protocol over rigid inheritance:

from typing import Protocol

# BAD: Forces inheritance
class Animal:
    def make_sound(self) -> str:
        raise NotImplementedError

class Dog(Animal):  # Must inherit
    def make_sound(self) -> str:
        return "Woof"

# GOOD: Duck typing with type safety
class CanMakeSound(Protocol):
    def make_sound(self) -> str: ...

class Dog:  # No inheritance needed
    def make_sound(self) -> str:
        return "Woof"

class Car:
    def make_sound(self) -> str:
        return "Vroom"

def hear_sound(thing: CanMakeSound) -> None:
    print(thing.make_sound())

hear_sound(Dog())  # OK
hear_sound(Car())  # OK (anything with make_sound method)

# Real-world example: file-like objects
class SupportsRead(Protocol):
    def read(self, n: int = -1) -> str: ...

def process_text_file(file: SupportsRead) -> int:
    content = file.read()
    return len(content)

# Works with any object that has a read() method
import io
process_text_file(io.StringIO("test"))  # OK
with open("file.txt") as f:
    process_text_file(f)  # OK

6. Type Narrowing

Help mypy understand type narrowing through conditionals:

def process_value(value: int | str | None) -> str:
    # mypy tracks type narrowing through conditionals

    if value is None:
        return "No value"
        # After this check, value is int | str in else branch

    if isinstance(value, int):
        return f"Number: {value}"
        # After this check, value is str in else branch

    # mypy knows value must be str here
    return f"Text: {value.upper()}"

# Use isinstance for type narrowing
def double_if_number(value: int | str) -> int | str:
    if isinstance(value, int):
        # mypy knows value is int here
        return value * 2
    # mypy knows value is str here
    return value

# Use type guards for custom narrowing
from typing import TypeGuard

def is_list_of_strings(val: list[Any]) -> TypeGuard[list[str]]:
    return all(isinstance(x, str) for x in val)

def process_list(items: list[Any]) -> None:
    if is_list_of_strings(items):
        # mypy knows items is list[str] here
        for item in items:
            print(item.upper())  # OK, item is str

Common Patterns

Forward References (String Annotations)

Use string annotations for forward references:

# Forward reference (class not yet defined)
class Node:
    def __init__(self, value: int, next: "Node | None" = None) -> None:
        self.value = value
        self.next = next

# Python 3.10+: Can use | with quotes
class TreeNode:
    def __init__(
        self,
        value: int,
        left: "TreeNode | None" = None,
        right: "TreeNode | None" = None
    ) -> None:
        self.value = value
        self.left = left
        self.right = right

# Python 3.7+: Use from __future__ import annotations
from __future__ import annotations

class ModernNode:
    def __init__(self, value: int, next: ModernNode | None = None) -> None:
        self.value = value
        self.next = next
    # No quotes needed with __future__ import!

Circular Type Dependencies

Handle circular dependencies with TYPE_CHECKING:

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    # Imports only used for type checking (not at runtime)
    from mypackage.models import User

class Post:
    def __init__(self, author: "User") -> None:
        self.author = author

# In models.py
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from mypackage.posts import Post

class User:
    def __init__(self, posts: list["Post"]) -> None:
        self.posts = posts

Generic Container Types

Type hint containers properly:

# Homogeneous lists
def process_names(names: list[str]) -> None:
    for name in names:
        print(name.upper())  # mypy knows name is str

# Mixed types (use Union)
def process_mixed(items: list[int | str]) -> None:
    for item in items:
        if isinstance(item, int):
            print(item * 2)
        else:
            print(item.upper())

# Nested containers
def process_matrix(matrix: list[list[float]]) -> float:
    return sum(sum(row) for row in matrix)

# Dict with specific key/value types
def process_scores(scores: dict[str, float]) -> float:
    return sum(scores.values())

# Complex nested structure
ComplexData = dict[str, list[dict[str, int | str]]]

def process_complex(data: ComplexData) -> None:
    pass

Callback Type Hints

Type hint callbacks and handlers:

from typing import Callable

# Simple callback
def run_with_callback(callback: Callable[[int], None]) -> None:
    callback(42)

run_with_callback(lambda x: print(x))  # OK

# Event handler
EventHandler = Callable[[str, dict[str, Any]], None]

def register_handler(event: str, handler: EventHandler) -> None:
    pass

def on_user_created(event_name: str, data: dict[str, Any]) -> None:
    print(f"Event: {event_name}, Data: {data}")

register_handler("user.created", on_user_created)

# Factory function
Factory = Callable[[], T]

def create_default(factory: Factory[T]) -> T:
    return factory()

create_default(lambda: [])  # Returns list
create_default(lambda: {})  # Returns dict

# Validator function
Validator = Callable[[str], bool]

def validate_email(email: str) -> bool:
    return "@" in email

def process_with_validation(value: str, validator: Validator) -> bool:
    return validator(value)

process_with_validation("test@example.com", validate_email)

Context Manager Type Hints

Type hint context managers properly:

from typing import Iterator, Generator
from contextlib import contextmanager

# Simple context manager class
class DatabaseConnection:
    def __enter__(self) -> "DatabaseConnection":
        print("Opening connection")
        return self

    def __exit__(self, exc_type, exc_val, exc_tb) -> None:
        print("Closing connection")

# Generator-based context manager
@contextmanager
def open_file(path: str) -> Iterator[list[str]]:
    file = open(path)
    try:
        yield file.readlines()
    finally:
        file.close()

# Generic context manager
from typing import Generic
T = TypeVar('T')

@contextmanager
def managed_resource(resource: T) -> Iterator[T]:
    try:
        yield resource
    finally:
        print(f"Cleaning up {resource}")

# Async context manager
class AsyncDatabaseConnection:
    async def __aenter__(self) -> "AsyncDatabaseConnection":
        print("Opening async connection")
        return self

    async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
        print("Closing async connection")

Anti-Patterns

Using Any Everywhere

# BAD: Defeats purpose of type hints
def process(data: Any) -> Any:
    return data

def calculate(x: Any, y: Any) -> Any:
    return x + y

# GOOD: Use specific types
def process_user(data: dict[str, str]) -> User:
    return User(**data)

def calculate_sum(x: int, y: int) -> int:
    return x + y

# ACCEPTABLE: Gradual typing transition
def legacy_function(data: Any) -> dict[str, Any]:
    # TODO: Add proper types once interface is stable
    return {"result": data}

Over-Complicated Type Hints

# BAD: Unreadable, overly complex
def transform(
    data: dict[str, list[tuple[int, str, dict[str, list[int | str | None]]]]]
) -> list[tuple[str, dict[str, list[int]]]]:
    pass

# GOOD: Use type aliases
InputRecord = tuple[int, str, dict[str, list[int | str | None]]]
InputData = dict[str, list[InputRecord]]
OutputRecord = tuple[str, dict[str, list[int]]]
OutputData = list[OutputRecord]

def transform_readable(data: InputData) -> OutputData:
    pass

# BETTER: Use TypedDict or dataclasses
from dataclasses import dataclass

@dataclass
class Record:
    id: int
    name: str
    metadata: dict[str, list[int | str | None]]

@dataclass
class TransformedRecord:
    name: str
    values: dict[str, list[int]]

def transform_structured(
    data: dict[str, list[Record]]
) -> list[TransformedRecord]:
    pass

Ignoring Type Errors Without Justification

# BAD: Silencing errors without understanding
result = risky_operation()  # type: ignore

# BAD: Generic ignore
value = complex_call()  # type: ignore

# GOOD: Specific error with explanation
# mypy doesn't understand this runtime type check
result = risky_operation()  # type: ignore[arg-type]  # See issue #456

# GOOD: Document temporary workaround
# TODO: Remove after upgrading to library v2.0 with type stubs
value = legacy_lib.call()  # type: ignore[no-untyped-call]

# BEST: Fix the underlying issue
def properly_typed_operation() -> int:
    return 42

result = properly_typed_operation()  # No ignore needed!

Not Running mypy in CI/CD

# BAD: Only running mypy locally
# Developers forget, type errors slip through

# GOOD: CI/CD pipeline (GitHub Actions example)
# .github/workflows/type-check.yml
"""
name: Type Check
on: [push, pull_request]
jobs:
  mypy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-python@v2
        with:
          python-version: '3.9'
      - run: pip install mypy
      - run: mypy src/
"""

# GOOD: pre-commit hook
# .pre-commit-config.yaml
"""
repos:
  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v0.991
    hooks:
      - id: mypy
        additional_dependencies: [types-requests]
"""

Inconsistent Type Hint Usage

# BAD: Inconsistent typing within same module
def get_user(user_id: int) -> dict:  # Untyped dict
    return {"id": user_id}

def create_user(name, email):  # No types at all
    return {"name": name, "email": email}

def delete_user(user_id: int) -> bool:  # Typed
    return True

# GOOD: Consistent typing throughout
class User(TypedDict):
    id: int
    name: str
    email: str

def get_user(user_id: int) -> User:
    return {"id": user_id, "name": "Unknown", "email": "unknown@example.com"}

def create_user(name: str, email: str) -> User:
    return {"id": 0, "name": name, "email": email}

def delete_user(user_id: int) -> bool:
    return True

Tools

mypy - Standard Type Checker

# Install
pip install mypy

# Basic usage
mypy file.py
mypy src/

# With config file
mypy --config-file mypy.ini src/

# Show error codes
mypy --show-error-codes src/

# Strict mode
mypy --strict src/

# Generate HTML report
mypy --html-report mypy-report src/

# Ignore missing imports
mypy --ignore-missing-imports src/

# Check specific Python version
mypy --python-version 3.9 src/

Pros:

  • Official type checker
  • Excellent documentation
  • Large community
  • Extensive configuration options

Cons:

  • Can be slow on large codebases
  • Sometimes conservative type inference

pyright - Microsoft Type Checker

# Install (requires Node.js)
npm install -g pyright

# Or use via pip
pip install pyright

# Basic usage
pyright src/

# Watch mode
pyright --watch src/

# VS Code integration
# Install "Pylance" extension (includes pyright)

Pros:

  • Very fast
  • Excellent IDE integration (VS Code)
  • Advanced type inference
  • Good error messages

Cons:

  • Different configuration than mypy
  • Less community adoption than mypy

pyre - Facebook Type Checker

# Install
pip install pyre-check

# Initialize
pyre init

# Run
pyre check

# Incremental mode
pyre start
pyre incremental

Pros:

  • Fast incremental checking
  • Good for large codebases
  • Advanced features (infer, query)

Cons:

  • Less widespread adoption
  • Primarily tested on Facebook's codebase

Type Stubs (.pyi files)

Create stub files for untyped code or third-party libraries:

mylib.pyi:

# Stub file (parallel to mylib.py)
def process(data: str) -> int: ...

class Handler:
    def handle(self, event: str) -> None: ...

Usage:

# Install type stubs for popular libraries
pip install types-requests
pip install types-redis
pip install types-PyYAML

# Search for available stubs
pip search types-

# Generate stubs from Python code
stubgen -p mypackage -o stubs/

Stub file best practices:

  • Use ... for function bodies
  • Include all public API
  • More permissive types than implementation
  • Keep in sync with actual code

Checklists

Type Hint Quality Checklist

When reviewing code for type hints, verify:

  • All public functions have parameter and return type hints
  • Type hints are accurate (match actual behavior)
  • Complex types use aliases for readability
  • No overuse of Any (each Any is justified)
  • Collection types are properly specified (e.g., list[str] not list)
  • Optional parameters use | None or Optional[]
  • *args and **kwargs are typed when used
  • Class attributes have type annotations
  • Properties have return type hints
  • No type: ignore without error code and explanation
  • Forward references use quotes or __future__.annotations
  • Union types use | operator (Python 3.10+) or Union[]
  • TypedDict used for structured dicts
  • Protocol used instead of forced inheritance where appropriate

mypy Configuration Checklist

For proper mypy setup, ensure:

  • mypy.ini or pyproject.toml configuration exists
  • Python version specified in config
  • warn_return_any enabled
  • warn_unused_configs enabled
  • no_implicit_optional enabled
  • warn_redundant_casts enabled
  • warn_unused_ignores enabled
  • strict_equality enabled
  • Per-module overrides for tests and third-party code
  • CI/CD pipeline runs mypy
  • Pre-commit hook for mypy (optional but recommended)
  • Team agrees on strictness level

Gradual Typing Migration Checklist

When adding types to existing codebase:

  • Start with most critical/public API functions
  • Add types to new code immediately
  • Focus on one module at a time
  • Use Any temporarily for complex migrations
  • Run mypy incrementally (not --strict initially)
  • Document known type issues in TODO comments
  • Create type stubs for untyped dependencies
  • Enable stricter mypy options gradually
  • Update documentation with type examples
  • Train team on type hint best practices

Examples

See the examples/ directory for comprehensive examples:

  • examples/basic_types.py - Basic type hint usage
  • examples/advanced_types.py - Generic types, protocols, TypedDict
  • examples/good_vs_bad.py - Common patterns vs anti-patterns
  • examples/mypy_config_examples/ - Sample mypy configurations

Templates

Template: mypy.ini Configuration

Located at: templates/mypy.ini

Use this as a starting point for mypy configuration in new projects.

Template: Type Stub File

Located at: templates/stub_file.pyi

Use this template when creating type stubs for untyped code.

Template: Typed Class

Located at: templates/typed_class.py

Example of a well-typed Python class with all common patterns.

Related Skills

  • python-testing-patterns: Type hints improve testability and test clarity
  • python-code-style: Type hints are part of PEP 8 style guidelines
  • python-async-patterns: Async functions require special type hint considerations

References

PEPs (Python Enhancement Proposals)

  • PEP 484: Type Hints (foundation)
  • PEP 526: Syntax for Variable Annotations
  • PEP 544: Protocols (structural subtyping)
  • PEP 585: Type Hinting Generics In Standard Collections (Python 3.9+)
  • PEP 586: Literal Types
  • PEP 589: TypedDict
  • PEP 591: Final qualifier
  • PEP 604: Union operator | (Python 3.10+)
  • PEP 612: Parameter Specification Variables
  • PEP 613: TypeAlias annotation
  • PEP 647: Type Guards

Official Documentation

External Resources


Version: 1.0 Last Updated: 2025-12-24 Target Python Version: 3.9+ Maintainer: Uncle Duke (Python Development Agent)