Skip to main content Brad's PyNotes

PEP 585 Generics

TL;DR

PEP 585 made built-in collections generic, allowing list[int] instead of List[int] - eliminating duplicate type hierarchies in the typing module.

Interesting!

Before PEP 585, Python had two separate type systems - one for runtime and one for type hints. Now built-in collections work for both!

Before and After

python code snippet start

# Old way (before Python 3.9)
from typing import List, Dict, Set, Tuple, Optional

def process_data(
    items: List[str],
    mapping: Dict[str, int], 
    unique_values: Set[float],
    coordinates: Tuple[int, int],
    config: Optional[Dict[str, str]]
) -> List[Dict[str, int]]:
    pass

# New way (Python 3.9+)
def process_data(
    items: list[str],
    mapping: dict[str, int],
    unique_values: set[float], 
    coordinates: tuple[int, int],
    config: dict[str, str] | None
) -> list[dict[str, int]]:
    pass

python code snippet end

All Generic Collections

python code snippet start

# Basic collections
numbers: list[int] = [1, 2, 3]
word_count: dict[str, int] = {"hello": 5, "world": 5}
unique_ids: set[str] = {"abc", "def", "ghi"}
point: tuple[float, float] = (3.14, 2.71)

# Nested generics
matrix: list[list[int]] = [[1, 2], [3, 4]]
user_groups: dict[str, list[str]] = {
    "admins": ["alice", "bob"],
    "users": ["charlie", "diana"]
}

# Complex nesting
api_response: dict[str, list[dict[str, int | str]]] = {
    "users": [
        {"id": 1, "name": "Alice"},
        {"id": 2, "name": "Bob"}
    ]
}

python code snippet end

Collections Module Generics

python code snippet start

from collections import defaultdict, deque, Counter
from collections.abc import Mapping, Sequence

# collections types are now generic too
word_lists: defaultdict[str, list[str]] = defaultdict(list)
task_queue: deque[str] = deque(["task1", "task2"])
letter_counts: Counter[str] = Counter("hello world")

# Abstract base classes
def process_mapping(data: Mapping[str, int]) -> None:
    for key, value in data.items():
        print(f"{key}: {value}")

def process_sequence(items: Sequence[str]) -> str:
    return ", ".join(items)

python code snippet end

Runtime Introspection

python code snippet start

# Type information is preserved at runtime
def analyze_type(obj):
    print(f"Type: {type(obj)}")
    if hasattr(type(obj), '__args__'):
        print(f"Generic args: {type(obj).__args__}")
    if hasattr(type(obj), '__origin__'):
        print(f"Origin: {type(obj).__origin__}")

# Create parameterized types
string_list = list[str]
int_dict = dict[str, int]

print(string_list)  # <class 'list[str]'>
print(int_dict)     # <class 'dict[str, int]'>

# Runtime type checking
def validate_list(items, expected_type):
    if not isinstance(items, list):
        return False
    # Note: Can't check generic parameters at runtime with isinstance
    return True

numbers = [1, 2, 3]
validate_list(numbers, list[int])  # True (but doesn't check int type)

python code snippet end

Gradual Migration

python code snippet start

# Support both old and new styles during transition
from typing import List, Dict  # Still works

# You can mix and match during migration
def legacy_function(old_style: List[str]) -> dict[str, int]:
    result: dict[str, int] = {}
    for item in old_style:
        result[item] = len(item)
    return result

# Future annotations for earlier Python versions
from __future__ import annotations

def forward_compatible(items: list[str]) -> dict[str, int]:
    # This syntax works even in Python 3.7+ with __future__ import
    return {item: len(item) for item in items}

python code snippet end

Real-World Usage

python code snippet start

def load_config() -> dict[str, str | int | bool]:
    """Load configuration with mixed value types."""
    return {
        "host": "localhost",
        "port": 8080,
        "debug": True,
        "max_connections": 100
    }

def process_api_data(
    response: dict[str, list[dict[str, str | int]]]
) -> list[tuple[str, int]]:
    """Extract user data from API response."""
    users = response.get("users", [])
    return [(user["name"], user["id"]) for user in users]

def cache_results(
    cache: dict[str, str],
    computations: list[tuple[str, callable]]
) -> None:
    """Cache expensive computation results."""
    for key, func in computations:
        if key not in cache:
            cache[key] = str(func())

# Usage with proper type hints
config = load_config()
api_data = {"users": [{"name": "Alice", "id": 1}]}
results = process_api_data(api_data)

cache: dict[str, str] = {}
computations: list[tuple[str, callable]] = [
    ("pi", lambda: 3.14159),
    ("answer", lambda: 42)
]
cache_results(cache, computations)

python code snippet end

Type Checking Benefits

python code snippet start

# Type checkers can now provide better error detection
def sum_numbers(values: list[int]) -> int:
    return sum(values)

# Type checker will catch this error:
# sum_numbers(["1", "2", "3"])  # Error: Expected list[int], got list[str]

# Correct usage:
sum_numbers([1, 2, 3])  # OK

# IDE autocompletion works better
user_data: dict[str, str] = {"name": "Alice"}
# IDE knows .keys() returns dict_keys[str]
# IDE knows .values() returns dict_values[str]

python code snippet end

PEP 585 eliminated the artificial separation between runtime types and type hints - making Python’s type system more intuitive and reducing import overhead!

Reference: PEP 585 – Type Hinting Generics In Standard Collections