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 bytes2. 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 theTypedDictare required unless marked withNotRequired[].total=False: All keys are optional unless marked withRequired[].
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 toTin theifbranch, but does not narrow theelsebranch.TypeIs[T]: Narrows argument toTin theifbranch AND narrows the remaining type in theelsebranch!
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())