Advanced Typing, Protocols, Overloads & Self

Mastering advanced static typing in Python requires leveraging Function Overloading (@overload), structural dictionary schemas (TypedDict), precise scalar constraints (Literal), fluent API return typing (Self / PEP 673), and advanced type narrowing via TypeIs (PEP 742).

This chapter details @overload dispatch definitions, TypedDict total configuration, Literal type guards, Self return types, and TypeIs narrowers.


1. Function Overloading with @overload

When a function’s return type varies based on the type of argument passed (e.g. passing str returns str; passing bytes returns bytes), using a simple Union type (str | bytes) loses precision for static type checkers.

Use @overload to define precise type signatures for static type checkers, followed by a single un-decorated implementation function:

from typing import overload

# 1. Overload Signature 1: str input -> str output
@overload
def process_data(payload: str) -> str: ...

# 2. Overload Signature 2: bytes input -> bytes output
@overload
def process_data(payload: bytes) -> bytes: ...

# 3. Actual Implementation (NOT decorated with @overload!)
def process_data(payload: str | bytes) -> str | bytes:
    if isinstance(payload, str):
        return payload.upper()
    return payload.hex().encode("utf-8")

# Mypy accurately infers 'res_a' as 'str', and 'res_b' as 'bytes'!
res_a = process_data("hello")  # Type is str
res_b = process_data(b"hello") # Type is bytes

2. Structural Dictionaries (TypedDict)

TypedDict allows type-checking standard Python dictionaries with fixed key names and value types:

from typing import TypedDict, NotRequired

class UserPayload(TypedDict):
    user_id: int
    username: str
    email: NotRequired[str]  # Key is optional in the dictionary schema!

# Validated statically by Mypy!
user: UserPayload = {"user_id": 42, "username": "alice"}
  • total=True (Default): All keys defined in the TypedDict are required unless marked with NotRequired[].
  • total=False: All keys are optional unless marked with Required[].

3. Fluent API Return Types (Self in PEP 673)

When building fluent builder patterns or method-chaining classes, returning self annotated with the base class type breaks type checking when called on a subclass instance!

PEP 673 introduced Self to dynamically represent the subtype of the calling instance:

from typing import Self

class BaseBuilder:
    def set_name(self, name: str) -> Self:  # Returns instance subtype!
        self.name = name
        return self

class DerivedBuilder(BaseBuilder):
    def set_role(self, role: str) -> Self:
        self.role = role
        return self

# Mypy correctly tracks that builder is 'DerivedBuilder' after set_name()!
builder = DerivedBuilder().set_name("Alice").set_role("Admin")

4. Advanced Narrowing: TypeIs (PEP 742) vs TypeGuard

Python 3.13 introduced TypeIs (PEP 742) to fix TypeGuard’s limitation in boolean else branches:

  • TypeGuard[T]: Narrows argument to T in the if branch, but does not narrow the else branch.
  • TypeIs[T]: Narrows argument to T in the if branch AND narrows the remaining type in the else branch!
from typing import TypeIs

def is_int(val: int | str) -> TypeIs[int]:
    return isinstance(val, int)

def process(x: int | str):
    if is_int(x):
        # x is narrowed to int
        print(x + 1)
    else:
        # PEP 742 TypeIs correctly narrows x to str in the else branch!
        print(x.upper())
Display Options
Appearance
Text Size
100%