6. Typing, Interfaces, Protocols
Use modern typing to encode invariants, enable tooling, and design flexible, well-typed interfaces.
Q1 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()
Q2 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"})
Q3 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
Q4 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
Q5 When to use NewType, Literal, or Annotated?
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.
Q6 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)
Q7 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)