Available for day contractsFrom 21st September I have availability for day and half day contracts. Please contact for more information.

Contact →
mikepreston.org

Python - Type Hints and Static Analysis

Type annotations for runtime validation and static analysis with mypy and pyright.

Python - Type Hints and Static Analysis

Type annotations for runtime validation and static analysis with mypy and pyright.

Overview

Python's type hint system provides optional static typing to improve code quality, catch bugs early, and enhance IDE support. Type hints use Python's annotation syntax to specify expected types for variables, function parameters, and return values. Static type checkers like mypy and pyright analyse code without running it, whilst the typing module provides advanced types for complex scenarios. Type hints don't affect runtime behaviour but serve as documentation and enable powerful tooling.

Type Systemtyping ModulePrimitivesGenericsProtocolsType VariablesSpecial FormsType Hint EcosystemSource Code withType HintsStatic Type CheckerRuntime TypeCheckingIDE ToolsmypypyrightpytypePydantictypeguardbeartypeAutocompleteRefactoringError DetectionType Systemtyping ModulePrimitivesGenericsProtocolsType VariablesSpecial FormsType Hint EcosystemSource Code withType HintsStatic Type CheckerRuntime TypeCheckingIDE ToolsmypypyrightpytypePydantictypeguardbeartypeAutocompleteRefactoringError Detection

Typing Primitives and Generics

Key Concepts

  • Basic types: int, str, float, bool, bytes represent primitive Python types
  • Collection types: builtin generics list[T], dict[K, V], set[T], tuple[...] (the typing.List/Dict/etc. aliases are deprecated since 3.9)
  • Unions: X | Y and X | None (PEP 604); Union[X, Y] and Optional[X] remain valid but are legacy spellings
  • Type aliases: Create reusable type definitions
  • Generic types: Parameterise types with TypeVar and Generic (or PEP 695 syntax on 3.12+)

Basic Type Annotations

# Variable annotations
name: str = "Alice"
age: int = 30
height: float = 5.8
is_active: bool = True
data: bytes = b"hello"

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

def add(a: int, b: int) -> int:
    return a + b

def process_data(items: list) -> None:
    """Process items without returning anything."""
    for item in items:
        print(item)

# Multiple return values (tuple)
def get_user_info(user_id: int) -> tuple[str, int, str]:
    """Return name, age, email."""
    return "Alice", 30, "alice@example.com"

# Builtin generics are standard since Python 3.9
def calculate(values: list[int]) -> int:
    return sum(values)

# Legacy syntax — typing.List/Dict/Set/Tuple etc. are deprecated since 3.9;
# you will still see them in older codebases, but don't write new code with them
from typing import List

def calculate_legacy(values: List[int]) -> int:
    return sum(values)

Collection Types

# Lists
def process_names(names: list[str]) -> list[str]:
    return [name.upper() for name in names]

# Dictionaries
def count_words(text: str) -> dict[str, int]:
    counts: dict[str, int] = {}
    for word in text.split():
        counts[word] = counts.get(word, 0) + 1
    return counts

# Sets
def unique_items(items: list[str]) -> set[str]:
    return set(items)

# Tuples with fixed length
def get_coordinates() -> tuple[float, float]:
    return 51.5074, -0.1278

# Tuples with variable length
def get_scores() -> tuple[int, ...]:
    return (95, 87, 92, 88)

# Nested collections
def get_matrix() -> list[list[int]]:
    return [[1, 2, 3], [4, 5, 6], [7, 8, 9]]

# Complex nested structures
def get_user_data() -> dict[str, list[tuple[str, int]]]:
    return {
        "purchases": [("item1", 100), ("item2", 200)],
        "views": [("page1", 50), ("page2", 75)]
    }

# Optional values: PEP 604 union with None
def find_user(user_id: int) -> str | None:
    users = {1: "Alice", 2: "Bob"}
    return users.get(user_id)  # Returns str or None

# Unions (PEP 604, Python 3.10+)
def parse_value(value: int | str) -> str:
    return str(value)

def get_result() -> str | None:
    return None

# Legacy spellings — Optional[str] and Union[int, str] remain valid,
# but prefer str | None and int | str in new code

Type Aliases

# Simple aliases (plain assignment works on every supported version)
UserId = int
UserName = str
EmailAddress = str

def create_user(user_id: UserId, name: UserName) -> None:
    pass

# Complex aliases
Vector = list[float]
Matrix = list[Vector]
Coordinates = tuple[float, float]

def calculate_distance(point1: Coordinates, point2: Coordinates) -> float:
    x1, y1 = point1
    x2, y2 = point2
    return ((x2 - x1) ** 2 + (y2 - y1) ** 2) ** 0.5

# JSON-like structures (forward references via strings)
JsonPrimitive = str | int | float | bool | None
JsonValue = JsonPrimitive | dict[str, "JsonValue"] | list["JsonValue"]
JsonObject = dict[str, JsonValue]

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

# Modern alias declaration: the `type` statement (PEP 695, Python 3.12+)
# type Point = tuple[float, float]
# type Route = list[Point]
#
# On 3.11, use plain assignment (above) or the explicit TypeAlias annotation:
from typing import TypeAlias

Point: TypeAlias = tuple[float, float]
Route: TypeAlias = list[Point]

def calculate_route_length(route: Route) -> float:
    total = 0.0
    for i in range(len(route) - 1):
        total += calculate_distance(route[i], route[i + 1])
    return total

# Function type aliases
from collections.abc import Callable

Validator = Callable[[str], bool]
Transformer = Callable[[str], str]
Handler = Callable[[int, str], None]

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

def is_email(value: str) -> bool:
    return "@" in value

result = apply_validator("test@example.com", is_email)

Generic Types

from typing import TypeVar, Generic

# Type variables
T = TypeVar("T")  # Can be any type
N = TypeVar("N", int, float)  # Constrained to int or float
S = TypeVar("S", bound=str)  # Must be str or subclass

# Generic function
def first_item(items: list[T]) -> T | None:
    """Return first item or None."""
    return items[0] if items else None

# Usage preserves types
numbers: list[int] = [1, 2, 3]
result1: int | None = first_item(numbers)  # Type is int | None

names: list[str] = ["Alice", "Bob"]
result2: str | None = first_item(names)  # Type is str | None

# PEP 695 generic function syntax (Python 3.12+) — no explicit TypeVar needed:
# def first_item[T](items: list[T]) -> T | None:
#     return items[0] if items else None

# Generic class
class Box(Generic[T]):
    """A container that holds a value of type T."""

    def __init__(self, value: T) -> None:
        self._value: T = value

    def get(self) -> T:
        return self._value

    def set(self, value: T) -> None:
        self._value = value

# PEP 695 generic class syntax (Python 3.12+):
# class Box[T]:
#     def __init__(self, value: T) -> None:
#         self._value: T = value
#     ...

# Usage
int_box: Box[int] = Box(42)
str_box: Box[str] = Box("hello")

# Multiple type parameters
KT = TypeVar("KT")  # Key type
VT = TypeVar("VT")  # Value type

class Cache(Generic[KT, VT]):
    """Generic cache with typed keys and values."""

    def __init__(self) -> None:
        self._data: dict[KT, VT] = {}

    def get(self, key: KT) -> VT | None:
        return self._data.get(key)

    def set(self, key: KT, value: VT) -> None:
        self._data[key] = value

    def items(self) -> list[tuple[KT, VT]]:
        return list(self._data.items())

# Usage
user_cache: Cache[int, str] = Cache()
user_cache.set(1, "Alice")
user_cache.set(2, "Bob")

# Generic with bounds
class Stack(Generic[T]):
    """Generic stack implementation."""

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

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

    def pop(self) -> T:
        return self._items.pop()

    def peek(self) -> T | None:
        return self._items[-1] if self._items else None

    def is_empty(self) -> bool:
        return len(self._items) == 0

    def size(self) -> int:
        return len(self._items)

# Constrained type variable
def add_numbers(a: N, b: N) -> N:
    """Add two numbers of the same numeric type."""
    return a + b  # type: ignore

result_int = add_numbers(1, 2)  # Returns int
result_float = add_numbers(1.5, 2.5)  # Returns float
# add_numbers("a", "b")  # Type error: str not allowed

# Generic protocols
from typing import Protocol

class Comparable(Protocol):
    """Protocol for objects that can be compared."""
    def __lt__(self, other: "Comparable") -> bool: ...
    def __gt__(self, other: "Comparable") -> bool: ...

CT = TypeVar("CT", bound=Comparable)

def find_max(items: list[CT]) -> CT | None:
    """Find maximum item using comparison."""
    if not items:
        return None
    max_item = items[0]
    for item in items[1:]:
        if item > max_item:
            max_item = item
    return max_item

Advanced Generic Patterns

from collections.abc import Callable
from typing import TypeVar, Generic, ParamSpec, Concatenate

# ParamSpec for callable signatures (Python 3.10+)
P = ParamSpec("P")
R = TypeVar("R")
T = TypeVar("T")

def log_call(func: Callable[P, R]) -> Callable[P, R]:
    """Decorator that logs function calls."""
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        print(f"Calling {func.__name__}")
        result = func(*args, **kwargs)
        print(f"Finished {func.__name__}")
        return result
    return wrapper

@log_call
def add(a: int, b: int) -> int:
    return a + b

# Concatenate for adding parameters
def with_logging(
    func: Callable[Concatenate[str, P], R]
) -> Callable[P, R]:
    """Adds a logging prefix parameter."""
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        return func("LOG", *args, **kwargs)
    return wrapper

# Generic dataclass
from dataclasses import dataclass

@dataclass
class Result(Generic[T]):
    """Generic result container."""
    success: bool
    data: T | None
    error: str | None = None

    @classmethod
    def ok(cls, data: T) -> "Result[T]":
        return cls(success=True, data=data)

    @classmethod
    def err(cls, error: str) -> "Result[T]":
        return cls(success=False, data=None, error=error)

# Usage
def divide(a: int, b: int) -> Result[float]:
    if b == 0:
        return Result.err("Division by zero")
    return Result.ok(a / b)

result = divide(10, 2)
if result.success:
    print(f"Result: {result.data}")  # Type checker knows data is float
else:
    print(f"Error: {result.error}")

# Generic with multiple constraints
TNum = TypeVar("TNum", int, float, complex)

class Calculator(Generic[TNum]):
    """Generic calculator for numeric types."""

    def __init__(self, initial: TNum) -> None:
        self.value: TNum = initial

    def add(self, other: TNum) -> "Calculator[TNum]":
        self.value = self.value + other  # type: ignore
        return self

    def get_result(self) -> TNum:
        return self.value

Protocols and Structural Subtyping

Key Concepts

  • Protocols: Define structural types (duck typing with type checking)
  • Structural subtyping: Types match based on structure, not inheritance
  • Runtime checkable: Use @runtime_checkable for isinstance checks
  • Protocol composition: Combine multiple protocols
  • Variance: Covariance, contravariance, and invariance in protocols
Nominal vs StructuralNominal TypingExplicit InheritanceStructural TypingShape Matchingisinstance checksStatic checking onlyStructural SubtypingYesNoProtocol DefinitionHas RequiredMethods?Type CompatibleType ErrorNominal vs StructuralNominal TypingExplicit InheritanceStructural TypingShape Matchingisinstance checksStatic checking onlyStructural SubtypingYesNoProtocol DefinitionHas RequiredMethods?Type CompatibleType Error

Basic Protocols

from typing import Protocol, runtime_checkable

# Simple protocol
class Drawable(Protocol):
    """Protocol for objects that can be drawn."""
    def draw(self) -> None:
        """Draw the object."""
        ...

# No inheritance needed - structural typing
class Circle:
    def draw(self) -> None:
        print("Drawing circle")

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

def render(shape: Drawable) -> None:
    """Render any drawable object."""
    shape.draw()

# Both work with render() due to structural typing
render(Circle())
render(Square())

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

class Container:
    def __init__(self, items: list) -> None:
        self._items = items

    @property
    def size(self) -> int:
        return len(self._items)

def print_size(obj: Sized) -> None:
    print(f"Size: {obj.size}")

print_size(Container([1, 2, 3]))

# Protocol with multiple methods
class Closeable(Protocol):
    """Protocol for closeable resources."""
    def open(self) -> None: ...
    def close(self) -> None: ...
    def is_open(self) -> bool: ...

class File:
    def __init__(self, path: str) -> None:
        self.path = path
        self._open = False

    def open(self) -> None:
        self._open = True
        print(f"Opening {self.path}")

    def close(self) -> None:
        self._open = False
        print(f"Closing {self.path}")

    def is_open(self) -> bool:
        return self._open

def manage_resource(resource: Closeable) -> None:
    """Manage any closeable resource."""
    resource.open()
    try:
        # Do work
        pass
    finally:
        if resource.is_open():
            resource.close()

Runtime Checkable Protocols

from typing import Any, Protocol, runtime_checkable

@runtime_checkable
class Comparable(Protocol):
    """Protocol for comparable objects."""
    def __lt__(self, other: "Comparable") -> bool: ...
    def __le__(self, other: "Comparable") -> bool: ...
    def __gt__(self, other: "Comparable") -> bool: ...
    def __ge__(self, other: "Comparable") -> bool: ...

class Product:
    def __init__(self, name: str, price: float) -> None:
        self.name = name
        self.price = price

    # Parameter must be at least as wide as the protocol's (contravariance);
    # Any satisfies that whilst keeping the implementation simple
    def __lt__(self, other: Any) -> bool:
        return self.price < other.price

    def __le__(self, other: Any) -> bool:
        return self.price <= other.price

    def __gt__(self, other: Any) -> bool:
        return self.price > other.price

    def __ge__(self, other: Any) -> bool:
        return self.price >= other.price

# Runtime check
product = Product("Widget", 9.99)
print(isinstance(product, Comparable))  # True

# Use in sorting
def sort_items(items: list[Comparable]) -> list[Comparable]:
    """Sort comparable items."""
    if not items:
        return items
    return sorted(items)

products: list[Comparable] = [
    Product("A", 10.0),
    Product("B", 5.0),
    Product("C", 7.5)
]
sorted_products = sort_items(products)

# Runtime validation
@runtime_checkable
class Serializable(Protocol):
    """Protocol for serializable objects."""
    def to_dict(self) -> dict: ...
    def from_dict(self, data: dict) -> None: ...

class User:
    def __init__(self, name: str, email: str) -> None:
        self.name = name
        self.email = email

    def to_dict(self) -> dict:
        return {"name": self.name, "email": self.email}

    def from_dict(self, data: dict) -> None:
        self.name = data["name"]
        self.email = data["email"]

def serialize_if_possible(obj: object) -> dict | None:
    """Serialize object if it implements the protocol."""
    if isinstance(obj, Serializable):
        return obj.to_dict()
    return None

user = User("Alice", "alice@example.com")
data = serialize_if_possible(user)  # Works
print(data)

Advanced Protocols

from collections.abc import Iterator
from typing import Protocol, TypeVar, Generic

T = TypeVar("T")
T_co = TypeVar("T_co", covariant=True)
T_contra = TypeVar("T_contra", contravariant=True)

# Generic protocol
class Container(Protocol[T_co]):
    """Protocol for containers that provide items of type T."""
    def __contains__(self, item: object) -> bool: ...
    def __iter__(self) -> Iterator[T_co]: ...
    def __len__(self) -> int: ...

class MyList(Generic[T]):
    def __init__(self, items: list[T]) -> None:
        self._items = items

    def __contains__(self, item: object) -> bool:
        return item in self._items

    def __iter__(self) -> Iterator[T]:
        return iter(self._items)

    def __len__(self) -> int:
        return len(self._items)

def count_items(container: Container[T]) -> int:
    """Count items in any container."""
    return len(container)

# Callable protocol
class Validator(Protocol):
    """Protocol for validation functions."""
    def __call__(self, value: str) -> bool: ...

def is_email(value: str) -> bool:
    return "@" in value

def is_uk_postcode(value: str) -> bool:
    import re
    pattern = r"^[A-Z]{1,2}\d[A-Z\d]? ?\d[A-Z]{2}$"
    return bool(re.match(pattern, value.upper()))

def validate_field(value: str, validator: Validator) -> bool:
    """Validate a field using any validator."""
    return validator(value)

# Both functions work as validators
result1 = validate_field("test@example.com", is_email)
result2 = validate_field("SW1A 1AA", is_uk_postcode)

# Protocol inheritance
class Readable(Protocol):
    """Protocol for readable objects."""
    def read(self) -> str: ...

class Writable(Protocol):
    """Protocol for writable objects."""
    def write(self, data: str) -> None: ...

class ReadWritable(Readable, Writable, Protocol):
    """Protocol combining reading and writing."""
    pass

class FileHandler:
    def __init__(self, content: str = "") -> None:
        self._content = content

    def read(self) -> str:
        return self._content

    def write(self, data: str) -> None:
        self._content = data

def copy_data(source: Readable, dest: Writable) -> None:
    """Copy data from source to destination."""
    data = source.read()
    dest.write(data)

# Protocol with class methods
class Instantiable(Protocol):
    """Protocol for classes that can be instantiated."""
    @classmethod
    def create(cls, name: str) -> "Instantiable": ...

class Product:
    def __init__(self, name: str) -> None:
        self.name = name

    @classmethod
    def create(cls, name: str) -> "Product":
        return cls(name)

def create_instance(cls: type[Instantiable], name: str) -> Instantiable:
    """Create an instance of any instantiable class."""
    return cls.create(name)

Variance in Protocols

from typing import Protocol, TypeVar, Generic

# Covariant (can return subtype)
T_co = TypeVar("T_co", covariant=True)

class Producer(Protocol[T_co]):
    """Protocol that produces values (covariant)."""
    def produce(self) -> T_co: ...

# Contravariant (can accept supertype)
T_contra = TypeVar("T_contra", contravariant=True)

class Consumer(Protocol[T_contra]):
    """Protocol that consumes values (contravariant)."""
    def consume(self, item: T_contra) -> None: ...

# Invariant (must be exact type)
T = TypeVar("T")

class Box(Protocol[T]):
    """Protocol for mutable container (invariant)."""
    def get(self) -> T: ...
    def set(self, value: T) -> None: ...

# Variance example
class Animal:
    def speak(self) -> str:
        return "Some sound"

class Dog(Animal):
    def speak(self) -> str:
        return "Woof"

class DogProducer:
    def produce(self) -> Dog:
        return Dog()

# Covariance: DogProducer is compatible with Producer[Animal]
def use_producer(producer: Producer[Animal]) -> None:
    animal = producer.produce()
    print(animal.speak())

use_producer(DogProducer())  # Works due to covariance

# Contravariance example
class AnimalConsumer:
    def consume(self, item: Animal) -> None:
        print(f"Consuming: {item.speak()}")

# AnimalConsumer is compatible with Consumer[Dog]
def use_consumer(consumer: Consumer[Dog], dog: Dog) -> None:
    consumer.consume(dog)

use_consumer(AnimalConsumer(), Dog())  # Works due to contravariance

Type Checking with mypy and pyright

Key Concepts

  • Static type checkers: Analyse code without running it
  • mypy: the de facto standard static type checker, configurable and extensible
  • pyright: Microsoft's fast type checker, powers VS Code's Python extension
  • Configuration: Control checking strictness and behaviour
  • Incremental checking: Check only changed files for speed
  • Type stubs: .pyi files for libraries without type hints
Strictness LevelsLenientSome CheckingNormalStandard CheckingStrictMaximum CheckingType Checking WorkflowYesNoSource CodeType CheckerType Errors?Report ErrorsSuccessConfigurationType StubsPluginsStrictness LevelsLenientSome CheckingNormalStandard CheckingStrictMaximum CheckingType Checking WorkflowYesNoSource CodeType CheckerType Errors?Report ErrorsSuccessConfigurationType StubsPlugins

mypy Setup and Configuration

# Installation
pip install mypy

# Basic usage
mypy script.py
mypy src/

# Check specific Python version
mypy --python-version 3.11 script.py

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

# Strict mode
mypy --strict script.py

# Incremental mode (faster)
mypy --incremental src/

# Configuration file
cat > mypy.ini << EOF
[mypy]
# Global options
python_version = 3.11
warn_return_any = True
warn_unused_configs = True
disallow_untyped_defs = True
disallow_any_generics = True
no_implicit_optional = True
warn_redundant_casts = True
warn_unused_ignores = True
warn_unreachable = True
strict_equality = True

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

[mypy-third_party.*]
ignore_missing_imports = True
EOF

pyproject.toml Configuration

# pyproject.toml for mypy
"""
[tool.mypy]
python_version = "3.11"
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
warn_unreachable = true
strict_equality = true
show_error_codes = true

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

[[tool.mypy.overrides]]
module = [
    "pandas.*",
    "numpy.*",
]
ignore_missing_imports = true
"""

# Example Python file with type hints
def process_users(
    users: list[dict[str, str]],
    min_age: int = 18
) -> list[str]:
    """
    Process users and return names.

    Args:
        users: List of user dictionaries
        min_age: Minimum age filter

    Returns:
        List of user names
    """
    result: list[str] = []
    for user in users:
        age_str: str | None = user.get("age")
        if age_str is not None:
            age: int = int(age_str)
            if age >= min_age:
                name: str = user["name"]
                result.append(name)
    return result

# mypy will check this thoroughly
users_data = [
    {"name": "Alice", "age": "30"},
    {"name": "Bob", "age": "15"},
]
names = process_users(users_data)

pyright Setup and Configuration

# Installation
npm install -g pyright

# Or use via pip
pip install pyright

# Basic usage
pyright script.py
pyright src/

# Watch mode
pyright --watch

# Generate type stubs for a library
pyright --createstub library_name
# pyrightconfig.json
"""
{
  "include": ["src"],
  "exclude": [
    "**/node_modules",
    "**/__pycache__",
    "tests"
  ],
  "ignore": ["temp/**"],
  "strict": ["src/core/**"],
  "typeCheckingMode": "basic",
  "reportMissingImports": true,
  "reportMissingTypeStubs": false,
  "pythonVersion": "3.11",
  "pythonPlatform": "Linux",
  "executionEnvironments": [
    {
      "root": "src",
      "pythonVersion": "3.11",
      "extraPaths": ["lib"]
    }
  ]
}
"""

# Type checking modes:
# - "off": No type checking
# - "basic": Basic type checking
# - "standard": Standard type checking (default)
# - "strict": Strictest type checking

Type Checking Examples

from typing import cast, Any

# Example 1: Missing return type (error in strict mode)
def calculate_total(items):  # Missing return annotation
    return sum(items)

# Fixed:
def calculate_total_fixed(items: list[float]) -> float:
    return sum(items)

# Example 2: Incompatible types
def greet(name: str) -> str:
    return f"Hello, {name}"

# greet(123)  # Error: Argument 1 has incompatible type "int"

# Example 3: Handling None in unions
def find_user(user_id: int) -> str | None:
    users = {1: "Alice", 2: "Bob"}
    return users.get(user_id)

user = find_user(1)
# print(user.upper())  # Error: Item "None" has no attribute "upper"

# Fixed with type narrowing:
if user is not None:
    print(user.upper())  # OK

# Example 4: Type narrowing with isinstance
def process_value(value: int | str) -> str:
    if isinstance(value, int):
        # Type checker knows value is int here
        return f"Number: {value * 2}"
    else:
        # Type checker knows value is str here
        return f"Text: {value.upper()}"

# Example 5: Any type (disallowed in strict mode)
def process_data(data: Any) -> Any:  # Too permissive
    return data.some_method()  # No checking

# Better:
from typing import Protocol

class Processable(Protocol):
    def process(self) -> str: ...

def process_better(data: Processable) -> str:
    return data.process()

# Example 6: Type guards
from typing import TypeGuard

def is_string_list(val: list[object]) -> TypeGuard[list[str]]:
    """Check if all items are strings."""
    return all(isinstance(item, str) for item in val)

def process_strings(items: list[object]) -> None:
    if is_string_list(items):
        # Type checker knows items is list[str] here
        for item in items:
            print(item.upper())  # OK

# Example 7: Literal types
from typing import Literal

Mode = Literal["read", "write", "append"]

def open_file(path: str, mode: Mode) -> None:
    print(f"Opening {path} in {mode} mode")

open_file("data.txt", "read")  # OK
# open_file("data.txt", "invalid")  # Error: invalid literal

# Example 8: TypedDict
from typing import TypedDict

class UserDict(TypedDict):
    name: str
    age: int
    email: str

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

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

# Missing key error:
# incomplete_user: UserDict = {"name": "Bob"}  # Error

# Example 9: Overload
from typing import overload

@overload
def get_item(container: list[str], index: int) -> str: ...

@overload
def get_item(container: dict[str, int], index: str) -> int: ...

def get_item(container, index):
    return container[index]

# Type checker knows the return type
result1: str = get_item(["a", "b"], 0)
result2: int = get_item({"x": 1}, "x")

# Example 10: Revealing types (debugging)
reveal_type(calculate_total_fixed([1.0, 2.0]))  # Shows: float

Handling Type Checker Errors

from typing import Any, cast, TYPE_CHECKING

# Ignore specific error
def legacy_function() -> int:
    return "not an int"  # type: ignore[return-value]

# Ignore entire line
result = some_untyped_library()  # type: ignore

# Cast for complex scenarios
data: dict[str, Any] = {"items": [1, 2, 3]}
items: list[int] = cast(list[int], data["items"])

# Assert type narrowing
def process_optional(value: str | None) -> str:
    assert value is not None  # Type narrowing
    return value.upper()  # OK after assert

# Import only for type checking (no runtime cost)
if TYPE_CHECKING:
    from expensive_module import HeavyClass

def use_heavy(obj: "HeavyClass") -> None:
    # obj is only checked statically, not at runtime
    pass

# Platform-specific types
import sys

if sys.platform == "win32":
    def windows_only() -> None:
        pass
elif sys.platform == "linux":
    def linux_only() -> None:
        pass

# Suppress errors for entire file
# mypy: ignore-errors

# Per-module configuration in code
# mypy: disallow-untyped-defs, warn-return-any

Gradual Typing Strategies

Key Concepts

  • Gradual typing: Mix typed and untyped code in the same project
  • Progressive adoption: Start with critical modules, expand over time
  • Type coverage: Track percentage of code with type hints
  • Stub files: Add types to third-party libraries
  • Dynamic typing: When to use Any and when to avoid it
StrategiesBottom-UpStart with utilitiesTop-DownStart withinterfacesCritical PathType hot paths firstGradual Typing JourneyUntyped CodebaseAdd Types to CoreType Public APIsType InternalFunctionsStrict ModeStrategiesBottom-UpStart with utilitiesTop-DownStart withinterfacesCritical PathType hot paths firstGradual Typing JourneyUntyped CodebaseAdd Types to CoreType Public APIsType InternalFunctionsStrict Mode

Progressive Type Adoption

# Phase 1: Start with function signatures (no body checking)
def process_data(data: list) -> list:
    # Untyped implementation
    result = []
    for item in data:
        result.append(item * 2)
    return result

# Phase 2: Add generic types
from typing import TypeVar

T = TypeVar("T")

def process_data_v2(data: list[T]) -> list[T]:
    # Still loose typing in body
    result = []
    for item in data:
        result.append(item * 2)  # Type checker might warn
    return result

# Phase 3: Full type annotations
N = TypeVar("N", int, float)

def process_data_v3(data: list[N]) -> list[N]:
    result: list[N] = []
    for item in data:
        # Type checker knows item is N (int or float)
        doubled: N = item * 2  # type: ignore[assignment]
        result.append(doubled)
    return result

# Phase 4: Type-safe with overloads
from typing import overload

@overload
def process_data_v4(data: list[int]) -> list[int]: ...

@overload
def process_data_v4(data: list[float]) -> list[float]: ...

def process_data_v4(data):
    return [item * 2 for item in data]

# Strategy 1: Type critical paths first
class UserService:
    """Start with public API methods."""

    def get_user(self, user_id: int) -> dict[str, Any] | None:
        """Public method - typed first."""
        return self._fetch_from_db(user_id)

    def _fetch_from_db(self, user_id):
        """Internal method - type later."""
        # Implementation
        pass

# Strategy 2: Use Any for gradual migration
from typing import Any

class LegacySystem:
    def process(self, data: Any) -> Any:
        """Accept anything for now."""
        # Legacy code
        return self._transform(data)

    def _transform(self, data: Any) -> Any:
        # Will type this later
        return data

# Strategy 3: Add types to new code only
class ModernService:
    """All new code is typed."""

    def new_feature(self, input_data: list[str]) -> dict[str, int]:
        """New method with full types."""
        return {item: len(item) for item in input_data}

    def legacy_method(self, data):
        """Old method left untyped for now."""
        # Don't touch legacy code yet
        pass

Type Coverage Tracking

# Using mypy with coverage report
mypy --html-report ./mypy-report src/

# Using pyright
pyright --stats

# Custom script to track coverage
cat > check_coverage.py << 'EOF'
#!/usr/bin/env python3
"""Track type hint coverage."""

import ast
import os
from pathlib import Path

def analyze_file(filepath: Path) -> tuple[int, int]:
    """
    Analyze a Python file for type hints.

    Returns:
        Tuple of (annotated_functions, total_functions)
    """
    with open(filepath) as f:
        tree = ast.parse(f.read())

    total = 0
    annotated = 0

    for node in ast.walk(tree):
        if isinstance(node, ast.FunctionDef):
            total += 1
            # Check if function has return annotation
            if node.returns is not None:
                # Check if all parameters have annotations
                all_params_annotated = all(
                    arg.annotation is not None
                    for arg in node.args.args
                    if arg.arg != "self" and arg.arg != "cls"
                )
                if all_params_annotated:
                    annotated += 1

    return annotated, total

def scan_directory(directory: Path) -> dict[str, tuple[int, int]]:
    """Scan directory for Python files."""
    results: dict[str, tuple[int, int]] = {}

    for filepath in directory.rglob("*.py"):
        if "__pycache__" in str(filepath):
            continue
        results[str(filepath)] = analyze_file(filepath)

    return results

if __name__ == "__main__":
    results = scan_directory(Path("src"))

    total_annotated = sum(r[0] for r in results.values())
    total_functions = sum(r[1] for r in results.values())

    coverage = (total_annotated / total_functions * 100) if total_functions > 0 else 0

    print(f"Type Coverage: {coverage:.1f}%")
    print(f"Annotated: {total_annotated}/{total_functions} functions")

    # Show files needing attention
    print("\nFiles with low coverage:")
    for filepath, (annotated, total) in results.items():
        if total > 0:
            file_coverage = annotated / total * 100
            if file_coverage < 50:
                print(f"  {filepath}: {file_coverage:.0f}%")
EOF

Stub Files for Untyped Libraries

# Create stub file for untyped library
# File: stubs/untyped_lib/__init__.pyi

"""Type stubs for untyped_lib."""

from typing import Any

class Connection:
    def __init__(self, host: str, port: int) -> None: ...
    def connect(self) -> bool: ...
    def disconnect(self) -> None: ...
    def execute(self, query: str) -> list[dict[str, Any]]: ...
    def close(self) -> None: ...

def create_connection(
    host: str,
    port: int = 5432,
    username: str | None = None,
    password: str | None = None,
) -> Connection: ...

def parse_config(config_file: str) -> dict[str, Any]: ...

# Configure mypy to use stubs
# mypy.ini:
"""
[mypy]
mypy_path = stubs
"""

# Using the stubbed library with full type checking
from untyped_lib import create_connection, Connection

def get_users() -> list[dict[str, Any]]:
    """Get users from database."""
    conn: Connection = create_connection("localhost", 5432)
    try:
        conn.connect()
        result: list[dict[str, Any]] = conn.execute("SELECT * FROM users")
        return result
    finally:
        conn.close()

Handling Dynamic Code

from typing import Any, TypeVar, cast, Protocol

# Strategy 1: TypedDict for dynamic dictionaries
from typing import TypedDict

class ConfigDict(TypedDict, total=False):
    """Configuration dictionary with known keys."""
    host: str
    port: int
    debug: bool
    options: dict[str, Any]

def load_config() -> ConfigDict:
    """Load configuration."""
    return {
        "host": "localhost",
        "port": 8000,
        "debug": True,
    }

config = load_config()
print(config["host"])  # Type checker knows this is str

# Strategy 2: Runtime type checking with protocols
class Configurable(Protocol):
    """Protocol for configurable objects."""
    def configure(self, options: dict[str, Any]) -> None: ...

def apply_config(obj: Configurable, config: dict[str, Any]) -> None:
    """Apply configuration to any configurable object."""
    obj.configure(config)

# Strategy 3: Generic factories
T = TypeVar("T")
T_co = TypeVar("T_co", covariant=True)  # mypy requires covariance here

class Factory(Protocol[T_co]):
    """Generic factory protocol."""
    def create(self, **kwargs: Any) -> T_co: ...

def create_instance(factory: Factory[T], **kwargs: Any) -> T:
    """Create instance using factory."""
    return factory.create(**kwargs)

# Strategy 4: Careful use of cast
def get_from_cache(key: str) -> Any:
    """Get value from untyped cache."""
    # Imagine this returns Any from Redis, etc.
    return {"name": "Alice", "age": 30}

def get_user_from_cache(user_id: int) -> dict[str, Any]:
    """Get user with type assertion."""
    result = get_from_cache(f"user:{user_id}")
    # We know the structure, so cast it
    return cast(dict[str, Any], result)

# Strategy 5: Type narrowing for dynamic access
def safe_get_attr(obj: Any, attr: str, default: T) -> T:
    """Safely get attribute with type preservation."""
    value = getattr(obj, attr, default)
    if isinstance(value, type(default)):
        return value
    return default

class Settings:
    timeout: int = 30
    debug: bool = False

settings = Settings()
timeout: int = safe_get_attr(settings, "timeout", 60)

Migration Best Practices

# Best Practice 1: Start with test files
import pytest

def test_calculator() -> None:
    """Typed test function."""
    from calculator import add, subtract

    result: int = add(2, 3)
    assert result == 5

    result2: int = subtract(5, 3)
    assert result2 == 2

# Best Practice 2: Type decorators and wrappers
from collections.abc import Callable
from functools import wraps
from typing import TypeVar, ParamSpec

P = ParamSpec("P")
R = TypeVar("R")

def typed_decorator(
    func: Callable[P, R]
) -> Callable[P, R]:
    """Decorator that preserves type information."""
    @wraps(func)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

@typed_decorator
def calculate(a: int, b: int) -> int:
    """Type information preserved through decorator."""
    return a + b

# Best Practice 3: Module-level __all__ with types
__all__: list[str] = [
    "calculate",
    "process_data",
    "UserService",
]

# Best Practice 4: Document migration status
"""
Module: data_processor
Type Hint Status: Partial (60% coverage)
Migration TODO:
    - Add types to _internal_helper functions
    - Replace Any with specific types in process_legacy
    - Add Protocol for plugin system
"""

# Best Practice 5: Name complex types with an alias
# Modern form — the `type` statement (PEP 695, Python 3.12+):
# type JsonValue = (
#     None | bool | int | float | str
#     | list["JsonValue"] | dict[str, "JsonValue"]
# )

# On 3.11, use TypeAlias (or plain assignment):
from typing import TypeAlias

JsonValue: TypeAlias = (
    None
    | bool
    | int
    | float
    | str
    | list["JsonValue"]
    | dict[str, "JsonValue"]
)

def process_json(data: JsonValue) -> str:
    """Process JSON data with proper typing."""
    return str(data)

Common Typing Patterns and Pitfalls

Key Concepts

  • Common patterns: Proven typing approaches for frequent scenarios
  • Pitfalls: Mistakes to avoid when using type hints
  • Best practices: Guidelines for maintainable type-annotated code
  • Performance: Type hints have no runtime performance cost
  • Forward references: Handle circular dependencies with string annotations

Common Patterns

from typing import Any, TypeVar, Generic, Protocol
from collections.abc import Callable, Iterator, Sequence
from contextlib import contextmanager

# Pattern 1: Builder pattern with types
T = TypeVar("T")

class QueryBuilder(Generic[T]):
    """Type-safe query builder."""

    def __init__(self, model: type[T]) -> None:
        self._model = model
        self._filters: list[str] = []
        self._limit: int | None = None

    def filter(self, condition: str) -> "QueryBuilder[T]":
        """Add filter condition."""
        self._filters.append(condition)
        return self

    def limit(self, count: int) -> "QueryBuilder[T]":
        """Set result limit."""
        self._limit = count
        return self

    def execute(self) -> list[T]:
        """Execute query and return results."""
        # Implementation
        return []

class User:
    pass

# Usage with preserved types
users: list[User] = (
    QueryBuilder(User)
    .filter("age > 18")
    .limit(10)
    .execute()
)

# Pattern 2: Repository pattern
from abc import ABC, abstractmethod

class Repository(ABC, Generic[T]):
    """Generic repository interface."""

    @abstractmethod
    def get(self, id: int) -> T | None:
        """Get entity by ID."""
        ...

    @abstractmethod
    def save(self, entity: T) -> T:
        """Save entity."""
        ...

    @abstractmethod
    def delete(self, id: int) -> bool:
        """Delete entity."""
        ...

    @abstractmethod
    def list(self) -> list[T]:
        """List all entities."""
        ...

class UserRepository(Repository[User]):
    """User repository implementation."""

    def get(self, id: int) -> User | None:
        # Implementation
        return None

    def save(self, entity: User) -> User:
        return entity

    def delete(self, id: int) -> bool:
        return True

    def list(self) -> list[User]:
        return []

# Pattern 3: Factory pattern with protocol
class Creatable(Protocol):
    """Protocol for classes that can be created."""
    @classmethod
    def create(cls, **kwargs: Any) -> "Creatable":
        ...

class Factory(Generic[T]):
    """Generic factory."""

    def __init__(self, cls: type[T]) -> None:
        self._cls = cls

    def create(self, **kwargs: Any) -> T:
        """Create instance."""
        return self._cls(**kwargs)  # type: ignore

# Pattern 4: Callable with specific signatures
ValidationFunc = Callable[[str], bool]
TransformFunc = Callable[[str], str]
AsyncHandler = Callable[[int, str], None]

def apply_validators(
    value: str,
    validators: list[ValidationFunc]
) -> bool:
    """Apply multiple validators."""
    return all(validator(value) for validator in validators)

# Pattern 5: Context manager with types
from typing import TypeVar, Generic

R = TypeVar("R")

@contextmanager
def managed_resource(resource_path: str) -> Iterator[str]:
    """Context manager for resources."""
    resource = f"Resource: {resource_path}"
    print(f"Acquiring {resource}")
    try:
        yield resource
    finally:
        print(f"Releasing {resource}")

# Usage
with managed_resource("/path/to/resource") as resource:
    print(f"Using {resource}")

# Pattern 6: Immutable data with frozen dataclass
from dataclasses import dataclass

@dataclass(frozen=True)
class Point:
    """Immutable point."""
    x: float
    y: float

    def distance_to(self, other: "Point") -> float:
        """Calculate distance to another point."""
        return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2) ** 0.5

# Pattern 7: Discriminated unions with Literal
from typing import Literal

@dataclass
class SuccessResult:
    status: Literal["success"]
    data: dict[str, Any]

@dataclass
class ErrorResult:
    status: Literal["error"]
    message: str
    code: int

Result = SuccessResult | ErrorResult

def process_result(result: Result) -> None:
    """Process result with type narrowing."""
    if result.status == "success":
        # Type checker knows this is SuccessResult
        print(f"Data: {result.data}")
    else:
        # Type checker knows this is ErrorResult
        print(f"Error {result.code}: {result.message}")

# Pattern 8: Singleton with types
class Singleton:
    """Thread-safe singleton with type hints."""
    _instance: "Singleton | None" = None

    def __new__(cls) -> "Singleton":
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance

    @classmethod
    def get_instance(cls) -> "Singleton":
        """Get singleton instance."""
        if cls._instance is None:
            cls._instance = cls()
        return cls._instance

Common Pitfalls

from typing import Any, TypeVar

# Pitfall 1: Mutable default arguments
def append_to_list(item: str, items: list[str] = []) -> list[str]:  # WRONG!
    """DON'T: Mutable default is shared across calls."""
    items.append(item)
    return items

# Fix: Use None and create new list
def append_to_list_fixed(
    item: str,
    items: list[str] | None = None
) -> list[str]:
    """DO: Create new list if None."""
    if items is None:
        items = []
    items.append(item)
    return items

# Pitfall 2: Writing new code with the deprecated typing aliases
from typing import List, Dict  # Deprecated since 3.9 — legacy code only

def process_items_legacy(items: List[str]) -> Dict[str, int]:
    """DON'T: typing.List/Dict still work but are deprecated."""
    return {item: len(item) for item in items}

# Use the builtin generics instead:
def process_items(items: list[str]) -> dict[str, int]:
    """DO: Use builtin generics (standard since 3.9)."""
    return {item: len(item) for item in items}

# Pitfall 3: Not handling None in unions
def get_user(user_id: int) -> str | None:
    """Returns username or None."""
    return None

# WRONG: Not checking for None
username = get_user(1)
# print(username.upper())  # Error: None has no attribute 'upper'

# FIX 1: Check explicitly
username = get_user(1)
if username is not None:
    print(username.upper())

# FIX 2: Provide default
username = get_user(1) or "Anonymous"
print(username.upper())

# Pitfall 4: Overly broad Any
def process_data(data: Any) -> Any:  # WRONG: Too permissive
    """This provides no type safety."""
    return data.some_method()  # No checking whatsoever

# Fix: Use proper types
from typing import Protocol

class Processable(Protocol):
    def some_method(self) -> str: ...

def process_data_fixed(data: Processable) -> str:  # BETTER
    """Now type-checked."""
    return data.some_method()

# Pitfall 5: Forgetting TypeVar bounds
T = TypeVar("T")

def combine(a: T, b: T) -> T:  # WRONG: Can't guarantee this works
    return a + b  # type: ignore  # Error: unsupported operand

# Fix: Use bound or constraint
Addable = TypeVar("Addable", int, float, str)

def combine_fixed(a: Addable, b: Addable) -> Addable:
    return a + b  # type: ignore  # Now constrained to addable types

# Pitfall 6: Circular imports
# file1.py
"""
from file2 import ClassB

class ClassA:
    def method(self, b: ClassB) -> None:  # Circular import!
        pass
"""

# Fix: Use string annotations or TYPE_CHECKING
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from file2 import ClassB

class ClassA:
    def method(self, b: "ClassB") -> None:  # String annotation
        pass

# Or use __future__ import (Python 3.7+)
from __future__ import annotations
# Now all annotations are strings automatically

# Pitfall 7: Not using Sequence for read-only operations
def sum_items(items: list[int]) -> int:  # WRONG: Too restrictive
    """This only accepts list, not tuple."""
    return sum(items)

# sum_items((1, 2, 3))  # Error: Expected list, got tuple

# Fix: Use Sequence for read-only
from collections.abc import Sequence

def sum_items_fixed(items: Sequence[int]) -> int:  # BETTER
    """Accepts list, tuple, and other sequences."""
    return sum(items)

sum_items_fixed([1, 2, 3])  # OK
sum_items_fixed((1, 2, 3))  # OK

# Pitfall 8: Contravariance confusion
from collections.abc import Callable

def process_with_callback(
    items: list[int],
    callback: Callable[[int], None]
) -> None:
    """Process items with callback."""
    for item in items:
        callback(item)

def handle_number(value: int | float) -> None:
    """Handle any number."""
    print(value)

# This works due to contravariance:
process_with_callback([1, 2, 3], handle_number)
# int | float is acceptable where int is expected

# Pitfall 9: Not specifying total=False in TypedDict
from typing import TypedDict

class UserDict(TypedDict):  # All fields required by default
    name: str
    email: str
    age: int

# user: UserDict = {"name": "Alice", "email": "a@b.com"}  # Error: missing 'age'

# Fix: Use total=False for optional fields
class UserDictFixed(TypedDict, total=False):
    name: str  # Optional
    email: str  # Optional
    age: int  # Optional

user: UserDictFixed = {"name": "Alice"}  # OK

# Or mix required and optional
class UserDictMixed(TypedDict):
    name: str  # Required
    email: str  # Required

class UserDictOptional(UserDictMixed, total=False):
    age: int  # Optional
    phone: str  # Optional

# Pitfall 10: Type narrowing doesn't propagate
def process_optional(value: int | None) -> None:
    """Type narrowing pitfall."""
    if value is not None:
        numbers = [value]
        # Type checker knows value is int here

    # But here value could be None again in some checkers
    # Always check again or use a different variable
    if value is not None:
        print(value + 10)  # Safe

# Better: Extract to variable after narrowing
def process_optional_better(value: int | None) -> None:
    """Better type narrowing."""
    if value is None:
        return

    # Now value is definitely int for the rest of the function
    numbers = [value]
    result = value + 10
    print(result)

Best Practices

from collections.abc import Sequence
from typing import TypeVar, Any, Protocol, Final, ClassVar

# Best Practice 1: Use Final for constants
from typing import Final

MAX_RETRIES: Final = 3
API_URL: Final[str] = "https://api.example.com"

# MAX_RETRIES = 5  # Error: Cannot assign to final

# Best Practice 2: Use ClassVar for class variables
from dataclasses import dataclass
from typing import ClassVar

@dataclass
class User:
    """User with class-level counter."""
    name: str
    email: str

    # Class variable
    user_count: ClassVar[int] = 0

    def __post_init__(self) -> None:
        User.user_count += 1

# Best Practice 3: Document complex types with aliases
# Instead of this everywhere:
# def process(data: dict[str, list[tuple[int, str, float]]]) -> None:
#     pass

# Use a named alias — the `type` statement on Python 3.12+:
# type DataPoint = tuple[int, str, float]
# type DataSet = dict[str, list[DataPoint]]

# On 3.11, use TypeAlias:
from typing import TypeAlias

DataPoint: TypeAlias = tuple[int, str, float]
DataSet: TypeAlias = dict[str, list[DataPoint]]

def process(data: DataSet) -> None:
    """Much clearer with named type."""
    pass

# Best Practice 4: Use NewType for distinct types
from typing import NewType

UserId = NewType("UserId", int)
ProductId = NewType("ProductId", int)

def get_user(user_id: UserId) -> str:
    """Get user by ID."""
    return f"User {user_id}"

def get_product(product_id: ProductId) -> str:
    """Get product by ID."""
    return f"Product {product_id}"

user_id = UserId(1)
product_id = ProductId(1)

get_user(user_id)  # OK
# get_user(product_id)  # Error: Expected UserId, got ProductId
# get_user(1)  # Error: Expected UserId, got int

# Best Practice 5: Type narrow early and consistently
def process_value(value: str | None) -> str:
    """Process value with early return."""
    # Early return for None
    if value is None:
        return ""

    # Now value is definitely str for rest of function
    cleaned = value.strip()
    uppercased = cleaned.upper()
    return uppercased

# Best Practice 6: Use Protocol for duck typing
class Drawable(Protocol):
    """Protocol instead of ABC for duck typing."""
    def draw(self) -> None: ...
    def get_bounds(self) -> tuple[int, int, int, int]: ...

def render_all(shapes: list[Drawable]) -> None:
    """Render any drawable objects."""
    for shape in shapes:
        shape.draw()

# No inheritance needed:
class Circle:
    def draw(self) -> None:
        print("Drawing circle")

    def get_bounds(self) -> tuple[int, int, int, int]:
        return (0, 0, 100, 100)

# Best Practice 7: Use ParamSpec for decorators
from collections.abc import Callable
from typing import ParamSpec, TypeVar
from functools import wraps

P = ParamSpec("P")
R = TypeVar("R")

def log_calls(func: Callable[P, R]) -> Callable[P, R]:
    """Decorator preserving signature."""
    @wraps(func)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        print(f"Calling {func.__name__}")
        result = func(*args, **kwargs)
        print(f"Finished {func.__name__}")
        return result
    return wrapper

@log_calls
def add(a: int, b: int) -> int:
    """Signature preserved through decorator."""
    return a + b

# Type checker knows signature:
result: int = add(1, 2)

# Best Practice 8: Type guard for runtime checks
from typing import TypeGuard

def is_string_list(val: list[Any]) -> TypeGuard[list[str]]:
    """Runtime check with type narrowing."""
    return all(isinstance(x, str) for x in val)

def process_strings(items: list[Any]) -> int:
    """Process list if all strings."""
    if is_string_list(items):
        # Type checker knows items is list[str] here
        return sum(len(s) for s in items)
    return 0

# Best Practice 9: Use Literal for string constants
from typing import Literal

LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR"]

def log(message: str, level: LogLevel = "INFO") -> None:
    """Log with type-safe levels."""
    print(f"[{level}] {message}")

log("Starting", "INFO")  # OK
# log("Error", "CRITICAL")  # Error: Invalid literal

# Best Practice 10: Document type assumptions
def calculate_average(numbers: Sequence[float]) -> float:
    """
    Calculate average of numbers.

    Args:
        numbers: Non-empty sequence of numbers

    Returns:
        The arithmetic mean

    Raises:
        ValueError: If numbers is empty

    Note:
        Type system can't enforce non-empty,
        so we check at runtime.
    """
    if not numbers:
        raise ValueError("Cannot calculate average of empty sequence")
    return sum(numbers) / len(numbers)

Quick Reference

Category Pattern Example
Basic Types Variable annotation name: str = "Alice"
Function annotation def greet(name: str) -> str:
Optional type def find(id: int) -> str | None:
Union value: int | str (legacy: Union[int, str])
Collections List items: list[str] (legacy: List[str])
Dictionary data: dict[str, int]
Tuple (fixed) coords: tuple[float, float]
Tuple (variable) scores: tuple[int, ...]
Generics Type variable T = TypeVar("T")
Generic class class Box(Generic[T]): or class Box[T]: (3.12+)
Generic function def first(items: list[T]) -> T | None: or def first[T](...) (3.12+)
Protocols Define protocol class Drawable(Protocol):
Runtime checkable @runtime_checkable
Protocol with generics class Container(Protocol[T]):
Type Checkers Run mypy mypy script.py
Strict mode mypy --strict src/
Run pyright pyright script.py
Ignore error result = func() # type: ignore
Advanced Type alias type Vector = list[float] (3.12+); Vector: TypeAlias = list[float] (3.11)
Callable Handler = Callable[[int], str] (collections.abc.Callable)
Literal Mode = Literal["r", "w", "a"]
TypedDict class User(TypedDict):
Protocol class Closeable(Protocol):
NewType UserId = NewType("UserId", int)
Final MAX_SIZE: Final = 100
TypeGuard def is_str_list(...) -> TypeGuard[list[str]]:

Common Issues and Solutions

Issue Solution
error: Incompatible return value type Check function return type matches annotation
error: Missing return statement Add return statement or use -> None
error: "list" requires type parameters Use list[int] not bare list (under disallow_any_generics)
error: Name "List" is not defined Legacy code importing typing aliases — prefer builtin list[...] instead
error: Optional type needs argument Use str | None (or legacy Optional[str]), not bare Optional
error: Argument has incompatible type Check parameter types match call arguments
X | Y syntax fails at runtime PEP 604 needs Python 3.10+ (or from __future__ import annotations)
Type checker not finding issues Enable stricter checking in configuration
Circular import with types Use if TYPE_CHECKING: or string annotations
Mutable default argument Use None as default, create mutable in function
None has no attribute X Check for None before accessing attributes
Callable signature not preserved Use ParamSpec in decorators (Python 3.10+)
Protocol not matching Ensure all protocol methods are implemented
TypeVar bound not working Use bound= or specify allowed types explicitly
type X = ... is a syntax error The type statement is Python 3.12+; on 3.11 use TypeAlias or plain assignment
Type narrowing not working Use isinstance checks or type guards
Any making everything pass Avoid Any; use Protocol or proper types
Type stubs not found Install types package: pip install types-requests
reveal_type not found typing.reveal_type exists since 3.11; older code should remove it after debugging

Related Topics

The following topics complement Python type hints and static analysis work, and would make excellent additions to your cheatsheet collection:

  1. Pydantic - Runtime type validation using type hints for data validation, serialisation, and settings management
  2. Python Patterns - Design patterns enhanced by type hints including Factory, Strategy, and Repository patterns
  3. Python Debugging - Debugging tools that leverage type information for better inspection and error detection
  4. FastAPI - Modern web framework built on type hints for automatic validation and documentation generation
  5. Python pytest - Testing framework with type-checked test fixtures and parametrisation
  6. Python AsyncIO - Asynchronous programming with proper type hints for coroutines and async iterators