6. Typing, Interfaces, Protocols
Use modern typing to encode invariants, enable tooling, and design flexible, well-typed interfaces.
Question: What is structural typing and how does Python support it?
Answer: Structural typing (or "static duck typing") is a system where type compatibility is determined by the object's structure—the methods and attributes it possesses—rather than its explicit inheritance from a base class. Python supports this via typing.Protocol
.
Explanation: A Protocol
defines an interface. Any class that implements the methods and attributes specified in the Protocol
is considered a valid subtype, even without explicitly inheriting from it. This allows for flexible, decoupled designs while still providing static type safety with tools like mypy
. The @runtime_checkable
decorator allows for isinstance()
checks against a protocol at runtime.
from typing import Protocol, TypeVar, Iterable, runtime_checkable
T = TypeVar("T")
@runtime_checkable
class SupportsClose(Protocol):
def close(self) -> None: ...
def close_all(items: Iterable[SupportsClose]) -> None:
for i in items:
i.close()
Question: When would you use
TypedDict
?
Answer: TypedDict
is used to provide type hints for dictionaries with a fixed set of string keys and specific value types. It's ideal for defining the "shape" of dictionary-like data, such as JSON API payloads, without the overhead of creating a full class.
Explanation: This allows static type checkers like mypy
to catch errors if you access a missing key or use the wrong value type, which would otherwise only be discovered at runtime.
from typing import TypedDict
class UserPayload(TypedDict):
user_id: int
username: str
is_active: bool
def process_user(data: UserPayload) -> None:
print(f"Processing user: {data['username']}")
# mypy will catch this error:
# process_user({"user_id": 1, "username": "test"})
Question: How do you type higher-order functions? (
ParamSpec
,Concatenate
)
Answer: Use ParamSpec
for forwarding callable parameter types and Concatenate
when you add parameters in wrappers.
Explanation: This preserves type information through decorators and adapters.
from typing import Callable, ParamSpec, TypeVar, Concatenate
P = ParamSpec("P"); R = TypeVar("R")
def log_calls(fn: Callable[P, R]) -> Callable[P, R]:
def wrapper(*a: P.args, **kw: P.kwargs) -> R:
print(fn.__name__)
return fn(*a, **kw)
return wrapper
Question: What is the
Self
type and when to use it?
Answer: typing.Self
annotates methods that return an instance of their class, useful for fluent APIs and subclass-friendly constructors.
Explanation: It improves precision over -> "MyClass"
in hierarchies.
from typing import Self
class Builder:
def add(self, x: int) -> Self:
return self
Question: When to use
NewType
,Literal
, orAnnotated
?
Answer: NewType
creates distinct nominal types (e.g., UserId
). Literal
restricts exact values (e.g., HTTP methods). Annotated
attaches metadata (e.g., validation) to types.
Explanation: They improve safety and expressiveness in APIs.
Question: How do you narrow unions at runtime with types? (
TypeGuard
)
Answer: Use typing.TypeGuard[T]
in a predicate to inform the type checker that, when it returns True
, the input is of type T
.
from typing import TypeGuard
def is_int(x: object) -> TypeGuard[int]:
return isinstance(x, int)
def f(x: int | str) -> int:
if is_int(x):
return x + 1 # narrowed to int
return len(x)
Question: How do you express multiple callable signatures? (
@overload
)
Answer: Use typing.overload
to declare separate type signatures, then provide a single implementation that handles all cases.
from typing import overload
@overload
def parse(x: bytes) -> dict: ...
@overload
def parse(x: str) -> dict: ...
def parse(x):
return _impl(x)