pyguides

Advanced Type Hints and Protocols

Basic type hints get you annotations on function signatures. Advanced Python type hints get you a real type system: structural subtyping, decorator signatures that survive wrapping, narrowing predicates, and exhaustiveness checks. This article covers the constructs that turn type hints from documentation into something a type checker can actively verify.

It assumes you’re comfortable with TypeVar, Generic, and the basics of the typing module. If not, read the intro to type hints and the generics guide first.

Protocols and structural subtyping

Most Python type checks are nominal: MyClass satisfies MyInterface only if it explicitly inherits from it. Protocol (PEP 544) flips that. A class satisfies a protocol structurally, just by having the right methods or attributes. No inheritance required.

from typing import Protocol
from collections.abc import Iterable

class SupportsClose(Protocol):
    def close(self) -> None: ...

class Resource:
    def close(self) -> None:
        print("closing resource")

def close_all(things: Iterable[SupportsClose]) -> None:
    for t in things:
        t.close()

close_all([open("/dev/null"), Resource()])  # OK structurally

Both open("/dev/null") and Resource() are accepted because each has a callable close() returning None. Neither declares it implements SupportsClose. That’s the point.

Protocols are usually a better fit than abstract base classes for cross-cutting interfaces (anything “closeable”, “JSON-encodable”, “sendable”) because they don’t require you to edit every existing class to make it conform.

Generic protocols work the same way. PEP 695 (3.12+) gives you terse syntax with inferred variance:

from typing import Protocol

class Mapper[T, U](Protocol):
    def map(self, value: T) -> U: ...

Pre-3.12 you need the explicit TypeVar and covariant=True/contravariant=True flags. Mixing the two styles in one class (class Mapper[T](Protocol[T])) is invalid; pick one.

The @runtime_checkable Trap

You can ask the type system to also work at runtime with @runtime_checkable:

from typing import Protocol, runtime_checkable

@runtime_checkable
class HasClose(Protocol):
    def close(self) -> None: ...

This lets you do isinstance(obj, HasClose). The catch: it only checks that the named attributes exist. It does not check signatures, return types, or whether the attribute is even callable.

class LooksClose:
    def close(self):        # wrong return type, takes self
        return 42

isinstance(LooksClose(), HasClose)  # True!

This is the most common foot-gun in advanced Python type hints. For anything that matters, prefer isinstance against a concrete type, an ABC from collections.abc, or write an explicit check. Reserve @runtime_checkable for cheap “does it quack at all” gates where signature fidelity doesn’t matter.

Self, @override, and method typing

When a subclass returns its own type, Self (PEP 673, 3.11+) replaces the awkward TypeVar('T', bound=...) pattern:

from typing import Self

class Builder:
    name: str = ""
    def with_name(self, name: str) -> Self:
        self.name = name
        return self

class FancyBuilder(Builder):
    def with_name(self, name: str) -> Self:
        self.name = f"!{name}"
        return self

Callers receive the subclass instance back, not a base Builder. Type checkers handle the variance correctly, so the fluent pattern keeps working as the hierarchy grows.

Pair it with @override (PEP 698, 3.12+) to catch signature drift:

from typing import override

class FancyBuilder(Builder):
    @override
    def with_name(self, name: str) -> Self:   # typo? Base renamed? Checker will complain.
        self.name = f"!{name}"
        return self

Runtime cost: nothing. @override exists so a typo or a renamed base method fails at type-check time rather than silently breaking polymorphism.

For the value side, Final (PEP 591) is the other constraint worth knowing. Mark a name Final and a checker rejects reassignment; mark a class Final and the checker rejects subclassing. Both catch the “someone edited this constant” class of bug at type-check time.

ParamSpec: decorators that preserve signatures

The classic decorator typing problem: a wrapper that calls f(*args, **kwargs) always types as Callable[..., Any], and now your checker has nothing to say about call sites. ParamSpec (PEP 612, 3.10+) fixes this by capturing the parameter list itself.

from typing import Callable, TypeVar, ParamSpec

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

def with_logging(f: Callable[P, R]) -> Callable[P, R]:
    def inner(*args: P.args, **kwargs: P.kwargs) -> R:
        print(f"calling {f.__name__}")
        return f(*args, **kwargs)
    return inner

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

add(1, 2)         # OK
add("1", "2")     # type error: expected int, got str

P.args and P.kwargs only work as annotations on *args and **kwargs of an inner function whose enclosing scope has P. They must travel as a pair; you can’t use one without the other, and you can’t store them in a module-level variable.

For argument injection, reach for Concatenate:

from typing import Concatenate

class Request: ...

def with_request(f: Callable[Concatenate[Request, P], R]) -> Callable[P, R]:
    def inner(*args: P.args, **kwargs: P.kwargs) -> R:
        return f(Request(), *args, **kwargs)
    return inner

Concatenate[X, P] means “a callable taking X followed by whatever P captured”. The decorator returns a callable that drops X, since X is now supplied inside. Concatenate works cleanly for positional params. Keyword-only transformation is still unreliable; that’s a known limitation.

TypeVarTuple for variadic generics

When a single TypeVar doesn’t cut it (tensor shapes, heterogeneous tuples, anything where the type list itself is a parameter), reach for TypeVarTuple (PEP 646, 3.11+):

from typing import TypeVarTuple, Unpack

Ts = TypeVarTuple("Ts")

def first[*Ts](*args: Unpack[Ts]) -> Unpack[Ts]:
    return args[0]

The PEP 695 native syntax (def first[*Ts]) is the modern form. The Unpack[Ts] (or the *Ts shorthand) expands the captured tuple wherever you’d write out the types manually. This is what libraries like NumPy and PyTorch use to type shape parameters and stacked operations.

TypeIs vs TypeGuard for narrowing

A predicate function narrows a union type in the if branch. The question is what happens in the else branch.

TypeGuard[T] (PEP 647, 3.10+) narrows only in the positive branch. The negative branch keeps the original union.

TypeIs[T] (PEP 742, 3.13+) narrows in both branches: positive becomes A ∧ R, negative becomes A ∧ ¬R. This matches how isinstance() actually behaves in your head.

from typing import TypeIs

def is_str(x: object) -> TypeIs[str]:
    return isinstance(x, str)

def f(x: str | int) -> None:
    if is_str(x):
        reveal_type(x)  # str
    else:
        reveal_type(x)  # int  (TypeGuard would have left this as str | int)

TypeIs is invariant in its argument: TypeIs[bytes] is not a subtype of TypeIs[str], even though bytes is a subtype of str. A narrower predicate is a different contract. The function’s return type must be consistent with the input type as well; def f(x: int) -> TypeIs[str] is rejected.

There’s a safety contract you can’t escape: TypeIs is unsound if the function doesn’t return True iff the argument is of type T. Type checkers can’t detect that. PEP 742’s advice: reach for TypeIs first; keep TypeGuard for the rare case where you specifically don’t want negative narrowing.

Type aliases, Annotated, and the type statement

Pre-3.12, the standard form was an explicit annotation:

from typing import TypeAlias

Vector: TypeAlias = list[float]

The modern form (PEP 695, 3.12+) drops the annotation entirely. The type statement makes the alias explicit, gives checkers a clear signal, and supports recursion in cases where the older assignment form cannot:

type Vector = list[float]

Pick type over the assignment form for new code on 3.12+. Checkers recognize it as an alias and resolve recursive definitions — a Json = dict[str, Json] | list[Json] | ... self-reference works with type, and not always with the older assignment form.

For tooling metadata that checkers should ignore, use Annotated (PEP 593):

from typing import Annotated

UserId = Annotated[int, "user identifier in our system"]

Libraries (Pydantic, FastAPI, attrs, Beartype) read the metadata; the checker still treats UserId as int. If you’re on Python < 3.9, the same shape lives in typing_extensions.

Common Pitfalls

A few traps that bite everyone once:

  • @runtime_checkable only checks attribute presence. Don’t use it as a type check.
  • TypeGuard’s else branch stays the original type. Use TypeIs for isinstance-style narrowing.
  • TypeIs is invariant in its argument. A narrower predicate is a different contract.
  • ParamSpec only works in Callable, Generic, and Concatenate. A bare def foo(x: P) is rejected.
  • P.args and P.kwargs must travel as a pair on the same inner function. Storing them in a module-level variable is rejected.
  • Don’t mix class Foo[T](Protocol[T]) or class Foo(Protocol[T], Generic[T]). Pick one syntax style per class.
  • Concatenate doesn’t reliably transform keyword-only parameters. A known limitation, not a bug to file.
  • Self is more than sugar for TypeVar('T', bound=...). It handles subclass variance correctly; use it.

If you’re on Python < 3.11, pull newer features from typing_extensions (Self, ParamSpec, Concatenate, TypeIs once available). The runtime imports are zero-cost shims.

Conclusion

The advanced Python type hints worth reaching for are the ones that catch real bugs at type-check time: @override for signature drift, ParamSpec for wrapped callables, TypeIs for narrowing that matches how you actually read the code, and Protocol for interfaces that don’t force every class to opt in. Use mypy, pyright, or pyrefly to enforce them; nothing in typing runs at runtime unless it’s explicitly runtime-aware.

Pick one feature from this article and apply it to a real module in your project this week. The payoff is checking that the next refactor doesn’t silently break a contract that was only ever written in a docstring.

See Also