Advanced Type System: PEP 695, ParamSpec, and Type Narrowing
Advanced Type System: PEP 695, ParamSpec, and Type Narrowing
After reading this chapter, you will master Python’s cutting-edge type system capabilities, leverage the PEP 695 type parameter syntax (type, generic functions, generic classes), author signature-preserving decorators using ParamSpec and Concatenate, eliminate false positives in type narrowing using TypeIs (PEP 742) versus TypeGuard, prevent injection vulnerabilities at compile time with LiteralString, and enforce exhaustive pattern matching using Never and assert_never.
Mental model
Static typing in Python has evolved from basic type annotations (PEP 484) to a sophisticated type algebra capable of modeling dynamic Python idioms without sacrificing safety:
┌─────────────────────────────────────────────────────────────┐
│ Evolution of Python Generics │
├──────────────────────────────┬──────────────────────────────┤
│ Python 3.5 - 3.11 (Verbose) │ Python 3.12+ PEP 695 (Clean) │
├──────────────────────────────┼──────────────────────────────┤
│ T = TypeVar("T") │ def first[T](seq: list[T]) │
│ Vector = list[T] │ type Vector[T] = list[T] │
│ class Box(Generic[T]): ... │ class Box[T]: ... │
└──────────────────────────────┴──────────────────────────────┘
Type Narrowing: TypeGuard vs TypeIs
When inspecting dynamic types at runtime, standard isinstance() checks narrow unions automatically. However, when factoring logic into reusable helper functions, standard return types (bool) lose type information.
Python provides two narrowing constructs: 1. TypeGuard[T] (PEP 647): Narrows only when returning True. In the else branch, the type is not narrowed. 2. TypeIs[T] (PEP 742 - Python 3.13+): True bidirectional narrowing. If is_str(x) returns True, x is str. If False, str is removed from the union in the else branch.
TypeIs Narrowing Matrix (x: int | str):
if is_str(x):
──▶ x is narrowed to str!
else:
──▶ x is narrowed to int! (TypeGuard leaves x as int | str)
Minimal example
Save as pep695_overview.py:
# pep695_overview.py
from typing import Sequence
# 1. PEP 695 Type Alias syntax
type ResultMatrix[T] = dict[str, list[T]]
# 2. PEP 695 Generic Function syntax
def find_first[T](items: Sequence[T], default: T) -> T:
# Returns first item or fallback default
return items[0] if items else default
# 3. PEP 695 Generic Class syntax
class BoundedStack[T]:
def __init__(self, capacity: int) -> None:
self._capacity = capacity
self._items: list[T] = []
def push(self, item: T) -> None:
if len(self._items) >= self._capacity:
raise OverflowError("Stack full")
self._items.append(item)
def pop(self) -> T:
return self._items.pop()
def main() -> None:
# Test generic function
first_num = find_first([10, 20, 30], default=0)
first_str = find_first([], default="fallback")
print(f"First num: {first_num} (type={type(first_num).__name__})")
print(f"First str: {first_str} (type={type(first_str).__name__})")
# Test generic class
stack: BoundedStack[str] = BoundedStack(capacity=2)
stack.push("alpha")
stack.push("beta")
print(f"\nPopped item: {stack.pop()}")
if __name__ == "__main__":
main()Run via uv run python pep695_overview.py:
First num: 10 (type=int)
First str: fallback (type=str)
Popped item: beta
Worked examples
Case 1: Signature-Preserving Decorators with ParamSpec and Concatenate
Before ParamSpec, decorating a function with *args: Any, **kwargs: Any erased parameter names, types, and defaults, crippling IDE autocomplete and static type checking. ParamSpec captures exact argument signatures:
# type_safe_decorator.py
from typing import Callable, Concatenate, ParamSpec, TypeVar
import time
P = ParamSpec("P")
R = TypeVar("R")
class SecurityContext:
def __init__(self, user: str, roles: set[str]) -> None:
self.user = user
self.roles = roles
def require_auth(
func: Callable[Concatenate[SecurityContext, P], R]
) -> Callable[P, R]:
# Decorator that injects a SecurityContext while preserving target signature
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
ctx = SecurityContext(user="operator_1", roles={"admin", "deploy"})
print(f"[AUTH] Injected context for user '{ctx.user}' into {func.__name__}()")
return func(ctx, *args, **kwargs)
return wrapper
@require_auth
def restart_cluster(ctx: SecurityContext, cluster_id: str, force: bool = False) -> str:
return f"Cluster '{cluster_id}' restarted by {ctx.user} (force={force})"
def main() -> None:
result = restart_cluster("prod-k8s-01", force=True)
print(f"Result: {result}")
if __name__ == "__main__":
main()Run:
uv run python type_safe_decorator.pyOutput:
[AUTH] Injected context for user operator_1 into restart_cluster()
Result: Cluster prod-k8s-01 restarted by operator_1 (force=True)
Case 2: Bidirectional Narrowing with TypeIs (PEP 742)
In domain models with heterogeneous unions, validating objects with helper predicates often requires narrowing in both if and else branches:
# type_is_narrowing.py
from typing import TypeIs
class HardwareNode:
def __init__(self, hostname: str, rack: str) -> None:
self.hostname = hostname
self.rack = rack
class VirtualInstance:
def __init__(self, instance_id: str, vcpu: int) -> None:
self.instance_id = instance_id
self.vcpu = vcpu
type ComputeTarget = HardwareNode | VirtualInstance
def is_hardware(node: ComputeTarget) -> TypeIs[HardwareNode]:
# Predicate function with PEP 742 TypeIs bidirectional narrowing
return isinstance(node, HardwareNode)
def dispatch_maintenance(node: ComputeTarget) -> str:
if is_hardware(node):
return f"Power cycle physical rack {node.rack} for {node.hostname}"
else:
return f"Live-migrate VM {node.instance_id} ({node.vcpu} vCPUs)"
def main() -> None:
hw = HardwareNode("blade-04.datacenter", rack="RACK-12")
vm = VirtualInstance("i-08a9fbc12", vcpu=8)
print(dispatch_maintenance(hw))
print(dispatch_maintenance(vm))
if __name__ == "__main__":
main()Run:
uv run python type_is_narrowing.pyOutput:
Power cycle physical rack RACK-12 for blade-04.datacenter
Live-migrate VM i-08a9fbc12 (8 vCPUs)
Case 3: Compile-Time Injection Immunity with LiteralString (PEP 675)
SQL injection and command injection happen when user-controlled strings are concatenated into raw query strings. LiteralString enforces that a parameter must consist solely of string literals authored in source code:
# injection_guard.py
from typing import LiteralString
class SafeQueryExecutor:
def execute(self, query: LiteralString, *params: object) -> str:
return f"Executing verified literal: {query} with params: {params}"
def main() -> None:
db = SafeQueryExecutor()
# 1. Compile-time safe: raw literal string with placeholders
res1 = db.execute("SELECT * FROM accounts WHERE id = ? AND status = ?", 42, "ACTIVE")
print(res1)
# 2. Compile-time safe: concatenating two literal strings
PART1: LiteralString = "SELECT * FROM metrics "
PART2: LiteralString = "ORDER BY timestamp DESC"
res2 = db.execute(PART1 + PART2)
print(res2)
if __name__ == "__main__":
main()Run:
uv run python injection_guard.pyOutput:
Executing verified literal: SELECT * FROM accounts WHERE id = ? AND status = ? with params: (42, ACTIVE)
Executing verified literal: SELECT * FROM metrics ORDER BY timestamp DESC with params: ()
Pitfalls
Pitfall 1: Assuming TypeGuard Narrows the else Branch
Developers frequently confuse TypeGuard with TypeIs. If a function returns TypeGuard[T], static type checkers only narrow the if branch. They do not exclude T in the else branch:
# THE TRAP:
from typing import TypeGuard
def is_int(val: int | str) -> TypeGuard[int]:
return isinstance(val, int)
def process(val: int | str):
if is_int(val):
print(val + 1)
else:
# BUGGY ASSUMPTION: The type checker treats val as (int | str), NOT str!
pass
# THE FIX:
# Use TypeIs (Python 3.13+) for full bidirectional narrowing:
from typing import TypeIs
def is_int_fixed(val: int | str) -> TypeIs[int]:
return isinstance(val, int)Pitfall 2: Forgetting assert_never in Exhaustive Pattern Matching
When matching over an enum or closed union, if a new variant is added to the union in the future, missing cases can slip into production unhandled. assert_never turns incomplete matches into static type-check errors:
# THE PATTERN:
from typing import assert_never
type Status = str
def handle_status(status: Status) -> str:
match status:
case "PENDING":
return "Waiting in queue"
case "RUNNING":
return "Executing job"
case "FAILED":
return "Alert sent"
case unexpected:
assert_never(unexpected)Exercises
- Refactor a legacy generic class using
TypeVarandGeneric[T]into PEP 695 syntax (class Cache[K, V]:). - Write a type-safe
@retrydecorator usingParamSpecthat preserves the wrapped function’s argument names and return type while adding an optionalmax_retriesconfiguration. - Implement a
TypeIsnarrowing functionis_valid_ipv4(addr: str | bytes)that narrowsaddrtostrwhen valid andbytesotherwise. - Build a secure shell command executor that accepts only
LiteralStringcommands, preventing shell injection from user inputs. - Create an exhaustive state machine transition function over a literal union of order states (
CREATED,PAID,SHIPPED,REFUNDED) usingmatch/caseandassert_never.
Further reading
- Python Documentation:
typing— Support for type hints. - PEP 695: Type Parameter Syntax.
- PEP 742: Narrowing types with TypeIs.
- PEP 612: Parameter Specification Variables (ParamSpec).
- PEP 675: Arbitrary Literal String Type (LiteralString).