Advanced Type System: PEP 695, ParamSpec, and Type Narrowing

Updated

September 7, 2026

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.py

Output:

[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.py

Output:

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.py

Output:

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

  1. Refactor a legacy generic class using TypeVar and Generic[T] into PEP 695 syntax (class Cache[K, V]:).
  2. Write a type-safe @retry decorator using ParamSpec that preserves the wrapped function’s argument names and return type while adding an optional max_retries configuration.
  3. Implement a TypeIs narrowing function is_valid_ipv4(addr: str | bytes) that narrows addr to str when valid and bytes otherwise.
  4. Build a secure shell command executor that accepts only LiteralString commands, preventing shell injection from user inputs.
  5. Create an exhaustive state machine transition function over a literal union of order states (CREATED, PAID, SHIPPED, REFUNDED) using match/case and assert_never.

Further reading

  • Python Documentation: typingSupport 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).