Type Hints and Protocols
Document useful interfaces for people and static tools while retaining explicit runtime validation.
By the end, you’ll be able to…
- Annotate functions and collections with precise types.
- Represent optional results and narrow them before use.
- Describe structural collaborators with Protocol.
01Annotations describe a contract
Type hints express intended inputs, outputs, and collection contents. They help an editor reveal an API and let an optional static checker detect mismatches before execution. Python itself does not enforce function or variable annotations. A function annotated with int can still receive text unless its implementation validates or naturally rejects the operation.
Use built-in generics such as list[str] and dict[str, int] in Python 3.12. Annotate public boundaries and places where the intended shape is not obvious; avoid adding noisy annotations to every local constant. A precise hint such as Mapping[str, int] can say that a function only reads a mapping rather than requiring one specific mutable dictionary.
02Make absence visible in the type
A search may find nothing. Returning str | None tells callers to account for that possibility instead of receiving a surprise attribute error. Narrow the result with if value is None before using string methods. Checking truthiness is different: an empty string may be a valid found value even though it is falsey.
Use object when a value may be anything but must be inspected before specific operations. Any disables much of static checking for the value and can spread uncertainty through a codebase. At an external-data boundary, validate the unknown structure once and return a precise internal type instead of labeling every decoded value Any indefinitely.
03Describe behavior with a protocol
A Protocol describes methods or attributes an object must provide to satisfy a structural interface. A function accepting a TextSink need not require callers to inherit a particular base class; an object with a compatible write method can satisfy the interface for static checking. This fits composition, where a workflow cares about a collaborator’s behavior.
Protocols let you state a small dependency instead of annotating a large concrete service. Keep the contract narrow and include parameter and return types. Static compatibility is stronger than simply having a same-named attribute: method signatures matter. An ordinary program still calls the methods normally at runtime.
04Keep static evidence separate from runtime checks
@runtime_checkable enables limited isinstance checks against a protocol, but those checks look for members rather than verifying complete method signatures or behavior. They are not a substitute for validation or a static type checker. Prefer a direct call when ordinary duck typing is enough, and use runtime checks only for a concrete reason.
The standard library supplies annotations and Protocol, but a comprehensive static type checker is an additional tool. This course’s examples remain runnable without one. If a project later adds a checker, configure its Python target and include it in the documented checks. Tests, runtime validation, and static analysis provide different evidence and complement one another.
The ideas worth keeping.
- Use hints to make interface intent precise.
- Narrow optional values before using them.
- Combine protocols, tests, and runtime checks according to the evidence each provides.
Make it yours.
Type and test your code right here in the interactive workspace. Run your solution, check the output, and compare with the worked guide when ready.
Handle an optional lookup
Write first_long(words, minimum) returning the first qualifying string or None. Demonstrate that the caller narrows the result before uppercasing it.
Explore a worked solution
from collections.abc import Iterabledef first_long(words: Iterable[str], minimum: int) -> str | None: for word in words: if len(word) >= minimum: return word return Noneresult = first_long(["a", "python"], 4)if result is None: print("no match")else: print(result.upper())assert first_long(["a"], 4) is NoneThe return hint documents absence. The caller handles None explicitly, which is both correct runtime control flow and useful static narrowing.
Inject a typed clock
Define a Clock protocol with now() returning an aware datetime. Implement FixedClock and use it in a due(deadline, clock) function. Include runtime awareness checks.
Explore a worked solution
from datetime import UTC, datetimefrom typing import Protocolclass Clock(Protocol): def now(self) -> datetime: ...class FixedClock: def __init__(self, value: datetime) -> None: self.value = value def now(self) -> datetime: return self.valuedef due(deadline: datetime, clock: Clock) -> bool: now = clock.now() if any(v.tzinfo is None or v.utcoffset() is None for v in (deadline, now)): raise ValueError("aware datetimes required") return now.astimezone(UTC) >= deadline.astimezone(UTC)instant = datetime(2026, 10, 20, tzinfo=UTC)assert due(instant, FixedClock(instant))The protocol describes the collaborator for static tools. Explicit checks enforce the awareness requirement that the datetime annotation cannot express by itself.
What stayed with you?
Check the ideas while they’re fresh. Your answers stay in this page and feedback appears as you go.
Handle an optional lookup
Write first_long(words, minimum) returning the first qualifying string or None. Demonstrate that the caller narrows the result before uppercasing it.
no match
Keep exploring.
The official references behind this chapter, when you want a second angle or the full details.
One chapter closer.
Reading is a start. Trying it yourself makes the connection.