| name | modern-syntax-and-types |
| description | Type system mastery and modern Python 3.10-3.12+ syntax, mypy/pyright configuration, generics, protocols |
Modern Python Syntax and Types
Overview
Core Principle: Type hints make code self-documenting and catch bugs before runtime. Python 3.10-3.12 introduced powerful type system features and syntax improvements. Use them.
Modern Python is statically typed (optionally), with match statements, structural pattern matching, and cleaner syntax. The type system evolved dramatically: | union syntax (3.10), exception groups (3.11), PEP 695 generics (3.12). Master these to write production-quality Python.
When to Use
Use this skill when:
- "mypy error: ..." or "pyright error: ..."
- Adding type hints to existing code
- Using Python 3.10+ features (match, | unions, generics)
- Configuring static type checkers
- Type errors with generics, protocols, or TypedDict
Don't use when:
- Setting up project structure (use project-structure-and-tooling)
- Runtime type checking needed (use pydantic or similar)
- Performance optimization (use debugging-and-profiling)
Symptoms triggering this skill:
- "Incompatible type" errors
- "How to type hint X?"
- "Use Python 3.12 features"
- "Configure mypy strict mode"
Type Hints Fundamentals
Basic Annotations
# ❌ WRONG: No type hints
def calculate_total(prices, tax_rate):
return sum(prices) * (1 + tax_rate)
# ✅ CORRECT: Clear types
def calculate_total(prices: list[float], tax_rate: float) -> float:
return sum(prices) * (1 + tax_rate)
# Why this matters: Type checker catches calculate_total([1, 2], "0.1")
# immediately instead of failing at runtime with TypeError
Built-in Collection Types (Python 3.9+)
# ❌ WRONG: Using typing.List, typing.Dict (deprecated)
from typing import List, Dict, Tuple
def process(items: List[str]) -> Dict[str, int]:
return {item: len(item) for item in items}
# ✅ CORRECT: Use built-in types directly (Python 3.9+)
def process(items: list[str]) -> dict[str, int]:
return {item: len(item) for item in items}
# ✅ More complex built-ins
def transform(data: dict[str, list[int]]) -> tuple[int, ...]:
all_values = [v for values in data.values() for v in values]
return tuple(all_values)
Why this matters: Python 3.9+ supports list[T] directly. Using typing.List is deprecated and adds unnecessary imports.
Optional and None
# ❌ WRONG: Using Optional without understanding
from typing import Optional
def get_user(id: int) -> Optional[dict]:
# Returns dict or None, but which dict structure?
...
# ✅ CORRECT: Use | None (Python 3.10+) with specific types
from dataclasses import dataclass
@dataclass
class User:
id: int
name: str
email: str
def get_user(id: int) -> User | None:
# Clear: Returns User or None
if user_exists(id):
return User(id=id, name="...", email="...")
return None
# Using the result
user = get_user(123)
if user is not None: # Type checker knows user is User here
print(user.name)
Why this matters: Optional[X] is just X | None. Python 3.10+ syntax is clearer. TypedDict or dataclass is better than raw dict.
Union Types
# ❌ WRONG: Old-style Union (Python <3.10)
from typing import Union
def process(value: Union[str, int, float]) -> Union[str, bool]:
...
# ✅ CORRECT: Use | operator (Python 3.10+)
def process(value: str | int | float) -> str | bool:
if isinstance(value, str):
return value.upper()
return value > 0
# ✅ Multiple returns with | None
def parse_config(path: str) -> dict[str, str] | None:
try:
with open(path) as f:
return json.load(f)
except FileNotFoundError:
return None
Why this matters: | is PEP 604, available Python 3.10+. Cleaner, more readable, Pythonic. No imports needed.
Type Aliases
# ❌ WRONG: Reusing complex types
def process_users(users: list[dict[str, str | int]]) -> dict[str, list[dict[str, str | int]]]:
...
# ✅ CORRECT: Type alias for readability
UserDict = dict[str, str | int]
UserMap = dict[str, list[UserDict]]
def process_users(users: list[UserDict]) -> UserMap:
return {"active": [u for u in users if u.get("active")]}
# ✅ BETTER: Use TypedDict for structure
from typing import TypedDict
class User(TypedDict):
id: int
name: str
email: str
active: bool
def process_users(users: list[User]) -> dict[str, list[User]]:
return {"active": [u for u in users if u["active"]]}
Why this matters: Type aliases improve readability. TypedDict provides structure validation for dict types.
Advanced Typing
Generics with TypeVar
from typing import TypeVar
T = TypeVar('T')
# ✅ Generic function
def first(items: list[T]) -> T | None:
return items[0] if items else None
# Usage: type checker knows the return type
names: list[str] = ["Alice", "Bob"]
first_name: str | None = first(names) # Type checker infers str | None
numbers: list[int] = [1, 2, 3]
first_num: int | None = first(numbers) # Type checker infers int | None
# ✅ Generic class (old style)
class Container(Generic[T]):
def __init__(self, value: T) -> None:
self.value = value
def get(self) -> T:
return self.value
# Usage
container: Container[int] = Container(42)
value: int = container.get() # Type checker knows it's int
Python 3.12+ Generics (PEP 695)
# ❌ WRONG: Old-style generic syntax (still works but verbose)
from typing import TypeVar, Generic
T = TypeVar('T')
class Container(Generic[T]):
def __init__(self, value: T) -> None:
self.value = value
# ✅ CORRECT: Python 3.12+ PEP 695 syntax
class Container[T]:
def __init__(self, value: T) -> None:
self.value = value
def get(self) -> T:
return self.value
# ✅ Generic function with PEP 695
def first[T](items: list[T]) -> T | None:
return items[0] if items else None
# ✅ Multiple type parameters
class Pair[T, U]:
def __init__(self, first: T, second: U) -> None:
self.first = first
self.second = second
def get_first(self) -> T:
return self.first
def get_second(self) -> U:
return self.second
# Usage
pair: Pair[str, int] = Pair("answer", 42)
Why this matters: PEP 695 (Python 3.12+) simplifies generic syntax. No TypeVar needed. Cleaner, more readable.
Bounded TypeVars
# ✅ TypeVar with bounds (works with old and new syntax)
from typing import TypeVar
# Bound to specific type
T_Number = TypeVar('T_Number', bound=int | float)
def add[T: int | float](a: T, b: T) -> T: # Python 3.12+ syntax
return a + b # Type checker knows a and b support +
# ✅ Constrained to specific types only
T_Scalar = TypeVar('T_Scalar', int, float, str)
def format_value(value: T_Scalar) -> str:
return str(value)
# Usage
result: int = add(1, 2) # OK
result2: float = add(1.5, 2.5) # OK
# result3 = add("a", "b") # mypy error: str not compatible with int | float
Protocol (Structural Subtyping)
from typing import Protocol
# ✅ Define protocol for duck typing
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")
# Works without inheritance - structural typing
def render(shape: Drawable) -> None:
shape.draw()
# Usage - no need to inherit from Drawable
circle = Circle()
square = Square()
render(circle) # OK
render(square) # OK
# ❌ WRONG: Using ABC when Protocol is better
from abc import ABC, abstractmethod
class DrawableABC(ABC):
@abstractmethod
def draw(self) -> None: ...
# Now Circle must inherit from DrawableABC - too rigid!
Why this matters: Protocol enables structural typing (duck typing with type safety). No inheritance needed. More Pythonic than ABC for many cases.
TypedDict
from typing import TypedDict
# ✅ Define structured dict types
class UserDict(TypedDict):
id: int
name: str
email: str
active: bool
def create_user(data: UserDict) -> UserDict:
# Type checker ensures all required keys present
return data
# Usage
user: UserDict = {
"id": 1,
"name": "Alice",
"email": "alice@example.com",
"active": True
}
# mypy error: Missing key "active"
# bad_user: UserDict = {"id": 1, "name": "Alice", "email": "alice@example.com"}
# ✅ Optional fields
class UserDictOptional(TypedDict, total=False):
bio: str
avatar_url: str
# ✅ Combining required and optional
class User(TypedDict):
id: int
name: str
class UserWithOptional(User, total=False):
email: str
bio: str
Why this matters: TypedDict provides structure for dict types. Better than dict[str, Any]. Type checker validates keys and value types.
Python 3.10+ Features
Match Statements (Structural Pattern Matching)
# ❌ WRONG: Long if-elif chains
def handle_response(response):
if response["status"] == 200:
return response["data"]
elif response["status"] == 404:
return None
elif response["status"] in [500, 502, 503]:
raise ServerError()
else:
raise UnknownError()
# ✅ CORRECT: Match statement (Python 3.10+)
def handle_response(response: dict[str, Any]) -> Any:
match response["status"]:
case 200:
return response["data"]
case 404:
return None
case 500 | 502 | 503:
raise ServerError()
case _:
raise UnknownError()
# ✅ Pattern matching with structure
def process_command(command: dict[str, Any]) -> str:
match command:
case {"action": "create", "type": "user", "data": data}:
return create_user(data)
case {"action": "delete", "type": "user", "id": user_id}:
return delete_user(user_id)
case {"action": action, "type": type_}:
return f"Unknown action {action} for {type_}"
case _:
return "Invalid command"
# ✅ Matching class instances
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
def describe_point(point: Point) -> str:
match point:
case Point(x=0, y=0):
return "Origin"
case Point(x=0, y=y):
return f"On Y-axis at {y}"
case Point(x=x, y=0):
return f"On X-axis at {x}"
case Point(x=x, y=y) if x == y:
return f"On diagonal at ({x}, {y})"
case Point(x=x, y=y):
return f"At ({x}, {y})"
Why this matters: Match statements are more readable than if-elif chains for complex conditionals. Pattern matching extracts values directly.
Python 3.11 Features
Exception Groups
# ❌ WRONG: Can't handle multiple exceptions from concurrent tasks
async def fetch_all(urls: list[str]) -> list[str]:
results = []
for url in urls:
try:
results.append(await fetch(url))
except Exception as e:
# Only logs first error, continues
log.error(f"Failed to fetch {url}: {e}")
return results
# ✅ CORRECT: Python 3.11 exception groups
async def fetch_all(urls: list[str]) -> list[str]:
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(fetch(url)) for url in urls]
# If any fail, TaskGroup raises ExceptionGroup
return [task.result() for task in tasks]
# Handling exception groups
try:
results = await fetch_all(urls)
except* TimeoutError as e:
# Handle all TimeoutErrors
log.error(f"Timeouts: {e.exceptions}")
except* ConnectionError as e:
# Handle all ConnectionErrors
log.error(f"Connection errors: {e.exceptions}")
# ✅ Creating exception groups manually
errors = [ValueError("Invalid user 1"), ValueError("Invalid user 2")]
raise ExceptionGroup("Validation errors", errors)
Why this matters: Exception groups handle multiple exceptions from concurrent operations. Essential for structured concurrency (TaskGroup).
Better Error Messages
Python 3.11 improved error messages significantly:
# Python 3.10 error:
# TypeError: 'NoneType' object is not subscriptable
# Python 3.11 error with exact location:
# TypeError: 'NoneType' object is not subscriptable
# user["name"]
# ^^^^^^^^^^^^
# Helpful for nested expressions
result = data["users"][0]["profile"]["settings"]["theme"]
# Python 3.11 shows exactly which part is None
Why this matters: Better error messages speed up debugging. Exact location highlighted.
Python 3.12 Features
PEP 695 Type Parameter Syntax
Already covered in Generics section above. Key improvement: cleaner syntax for generic classes and functions.
# Old style (still works)
from typing import TypeVar, Generic
T = TypeVar('T')
class Box(Generic[T]):
...
# Python 3.12+ style
class Box[T]:
...
@override Decorator
from typing import override
class Base:
def process(self) -> None:
print("Base process")
class Derived(Base):
@override
def process(self) -> None: # OK - overriding Base.process
print("Derived process")
@override
def compute(self) -> None: # mypy error: Base has no method 'compute'
print("New method")
# Why use @override:
# 1. Documents intent explicitly
# 2. Type checker catches typos (processs vs process)
# 3. Catches issues when base class changes
Why this matters: @override makes intent explicit and catches errors when base class changes or method names have typos.
f-string Improvements
# Python 3.12 allows more complex expressions in f-strings
# ✅ Reusing quotes in f-strings
value = "test"
result = f"Value is {value.replace('t', 'T')}" # Works in 3.12
# ✅ Multi-line f-strings with backslashes
message = f"Processing {
len(items)
} items"
# ✅ f-string debugging with = (since 3.8, improved in 3.12)
x = 42
print(f"{x=}") # Output: x=42
print(f"{x * 2=}") # Output: x * 2=84
Static Analysis Setup
mypy Configuration
File: pyproject.toml
[tool.mypy]
python_version = "3.12"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
disallow_untyped_decorators = true
warn_redundant_casts = true
warn_unused_ignores = true
warn_no_return = true
warn_unreachable = true
strict_equality = true
strict = true
# Per-module options
[[tool.mypy.overrides]]
module = "tests.*"
disallow_untyped_defs = false # Tests can be less strict
[[tool.mypy.overrides]]
module = "third_party.*"
ignore_missing_imports = true
Strict mode breakdown:
strict = true: Enables all strict checksdisallow_untyped_defs: All functions must have type hintswarn_return_any: Warn when returning Any typewarn_unused_ignores: Warn on unnecessary# type: ignore
When to use strict mode:
- New projects: Start strict from day 1
- Existing projects: Enable incrementally per module
pyright Configuration
File: pyproject.toml
[tool.pyright]
pythonVersion = "3.12"
typeCheckingMode = "strict"
reportMissingTypeStubs = false
reportUnknownMemberType = false
# Stricter checks
reportUnusedImport = true
reportUnusedVariable = true
reportDuplicateImport = true
# Exclude patterns
exclude = [
"**/__pycache__",
"**/node_modules",
".venv",
]
pyright vs mypy:
- pyright: Faster, better IDE integration, stricter by default
- mypy: More configurable, wider adoption, plugin ecosystem
Recommendation: Use both if possible. pyright in IDE, mypy in CI.
Dealing with Untyped Libraries
# ❌ WRONG: Silencing all errors
import untyped_lib # type: ignore
# ✅ CORRECT: Create stub file
# File: stubs/untyped_lib.pyi
def important_function(x: int, y: str) -> bool: ...
class ImportantClass:
def method(self, value: int) -> None: ...
# Configure mypy to find stubs
# pyproject.toml:
# mypy_path = "stubs"
# ✅ Use # type: ignore with explanation
from untyped_lib import obscure_function # type: ignore[import] # TODO: Add stub
# ✅ Use cast when library returns Any
from typing import cast
result = cast(list[int], untyped_lib.get_items())
Why this matters: Stubs preserve type safety even with untyped libraries. Type: ignore should be specific and documented.
Common Type Errors and Fixes
Incompatible Types
# mypy error: Incompatible types in assignment (expression has type "str | None", variable has type "str")
# ❌ WRONG: Ignoring the error
name: str = get_name() # type: ignore
# ✅ CORRECT: Handle None case
name: str | None = get_name()
if name is not None:
process_name(name)
# ✅ CORRECT: Provide default
name: str = get_name() or "default"
# ✅ CORRECT: Assert if you're certain
name = get_name()
assert name is not None
process_name(name)
List/Dict Invariance
# mypy error: Argument has incompatible type "list[int]"; expected "list[float]"
def process_numbers(numbers: list[float]) -> None:
...
int_list: list[int] = [1, 2, 3]
# process_numbers(int_list) # mypy error!
# Why: Lists are mutable. If process_numbers did numbers.append(3.14),
# it would break int_list type safety
# ✅ CORRECT: Use Sequence for read-only
from collections.abc import Sequence
def process_numbers(numbers: Sequence[float]) -> None:
# Can't modify, so safe to accept list[int]
...
process_numbers(int_list) # OK now
Missing Return Type
# mypy error: Function is missing a return type annotation
# ❌ WRONG: No return type
def calculate(x, y):
return x + y
# ✅ CORRECT: Add return type
def calculate(x: int, y: int) -> int:
return x + y
# ✅ Functions that don't return
def log_message(message: str) -> None:
print(message)
Generic Type Issues
# mypy error: Need type annotation for 'items'
# ❌ WRONG: No type for empty container
items = []
items.append(1) # mypy can't infer type
# ✅ CORRECT: Explicit type annotation
items: list[int] = []
items.append(1)
# ✅ CORRECT: Initialize with values
items = [1, 2, 3] # mypy infers list[int]
Anti-Patterns
Over-Typing
# ❌ WRONG: Too specific, breaks flexibility
def process_items(items: list[str]) -> list[str]:
return [item.upper() for item in items]
# Can't pass tuple, generator, or other iterables
# ✅ CORRECT: Use abstract types
from collections.abc import Sequence
def process_items(items: Sequence[str]) -> list[str]:
return [item.upper() for item in items]
# Now works with list, tuple, etc.
Type: Ignore Abuse
# ❌ WRONG: Blanket ignore
def sketchy_function(data): # type: ignore
return data["key"]
# ✅ CORRECT: Specific ignore with comment
def legacy_integration(data: dict[str, Any]) -> Any:
# type: ignore[no-untyped-def] # TODO(#123): Add proper types
return data["key"]
# ✅ BETTER: Fix the issue
def fixed_integration(data: dict[str, str]) -> str:
return data["key"]
Using Any Everywhere
# ❌ WRONG: Any defeats the purpose of types
def process(data: Any) -> Any:
return data.transform()
# ✅ CORRECT: Use specific types
from typing import Protocol
class Transformable(Protocol):
def transform(self) -> str: ...
def process(data: Transformable) -> str:
return data.transform()
Incompatible Generics
# ❌ WRONG: Generic type mismatch
T = TypeVar('T')
def combine(a: list[T], b: list[T]) -> list[T]:
return a + b
ints: list[int] = [1, 2]
strs: list[str] = ["a", "b"]
# result = combine(ints, strs) # mypy error: incompatible types
# ✅ CORRECT: Different type parameters
T1 = TypeVar('T1')
T2 = TypeVar('T2')
def combine_any[T1, T2](a: list[T1], b: list[T2]) -> list[T1 | T2]:
return a + b # type: ignore[return-value] # Runtime works, typing is complex
# ✅ BETTER: Keep types consistent
result_ints = combine(ints, [3, 4]) # OK: both list[int]
Decision Trees
When to Use Which Type?
For functions accepting sequences:
Read-only? → Sequence[T]
Need indexing? → Sequence[T]
Need mutation? → list[T]
Large data? → Iterator[T] or Generator[T]
For dictionary-like types:
Known structure? → TypedDict
Dynamic keys? → dict[K, V]
Protocol needed? → Mapping[K, V] (read-only)
Need mutation? → MutableMapping[K, V]
For optional values:
Can be None? → T | None
Has default? → T with default parameter
Really optional? → T | None in TypedDict(total=False)
Integration with Other Skills
After using this skill:
- If setting up project → See @project-structure-and-tooling for mypy in pyproject.toml
- If fixing lint → See @systematic-delinting for type-related lint rules
- If testing typed code → See @testing-and-quality for pytest type checking
Before using this skill:
- Setup mypy → Use @project-structure-and-tooling first
Quick Reference
| Python Version | Key Type Features |
|---|---|
| 3.9 | Built-in generics (list[T] instead of List[T]) |
| 3.10 | Union with ` |
| 3.11 | Exception groups, Self type, better errors |
| 3.12 | PEP 695 generics, @override decorator |
Most impactful features:
| Noneinstead ofOptional(3.10+)- Built-in generics:
list[T]notList[T](3.9+) - PEP 695:
class Box[T]notclass Box(Generic[T])(3.12+) - Match statements for complex conditionals (3.10+)
- @override for explicit method overriding (3.12+)