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, 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.

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)