Type Hints in Functions
Source: Fluent Python, 2nd Edition — Chapter 8 by Luciano Ramalho
Python has a gradual type system: annotations are optional, a checker like Mypy assumes Any (compatible with everything) wherever a hint is missing, and — this is the part people trip on — the Python interpreter never enforces any of it. Type hints exist purely for static tools (type checkers, IDEs, linters) to catch bugs by reading your source code, without running it.
Every example in this lesson runs with real Python, not a type checker, so you'll see the flip side of that directly: code with "wrong" type hints executes exactly the same as code without them. The hints are for the tools, not the runtime.
Gradual typing: optional, unenforced, no speedup
A gradual type system has three defining properties: it's optional (untyped code is never flagged by default), it does not catch type errors at runtime, and it does not improve performance — annotations carry no bytecode-level benefit today.
🧪 A "wrong" argument still runs
A type checker like Mypy would reject that last call — 'surprise' is not int. Plain Python doesn't care; it just runs the code, and in this case the string happens to be truthy so nothing crashes. That gap — between what the annotation claims and what the interpreter checks — is the whole reason type checkers exist as a separate tool in your CI pipeline, not a language feature.
Mini quiz — gradual typing
Properties of a gradual type system
1 / 2By default, a type checker on an unannotated function
Pick an answer to see if you are right.
Challenge: fully annotate show_count
Add the type hints from the prose above: count: int, word: str, return type str.
Duck typing vs. nominal typing
Plain Python uses duck typing: an object's declared type never matters, only whether the operation you attempt actually works at the moment you attempt it. Annotated Python adds nominal typing on top, checked statically: a type checker only cares what a variable is declared as, and rejects operations that type doesn't advertise — even if, at runtime, the actual object would have handled it fine.
🧪 What a type checker would flag, still runs
Mypy flags alert_bird's own body — Bird has no .quack() — the moment it reads birds.py, regardless of how the function is ever called. Calling alert_bird(daffy) here works at runtime because daffy actually is a Duck. But alert_bird(woody) blows up with AttributeError, exactly the failure Mypy was warning about — just discovered at runtime instead of before you shipped it.
Mini quiz — duck typing vs. nominal typing
Two views of type
1 / 2Duck typing is enforced
Pick an answer to see if you are right.
Challenge: bird hierarchy
alert_bird(birdie: Bird) -> None calls birdie.quack(). Wire that call up, then watch it succeed for a Duck and fail for a plain Bird.
Any vs. object, and "consistent-with"
Any is a wildcard: every type is consistent with Any, and Any is consistent with every type — a type checker lets anything flow through it, in either direction. object is completely different: everything is a subtype of object, so it accepts every value too, but a checker only lets you use operations that object itself defines (which is almost nothing). Also worth knowing: PEP 484 special-cases the numeric tower so int is consistent-with float, and float is consistent-with complex — even though none of them actually subclass each other.
🧪 int flows through a float parameter
Mini quiz — Any, object, and consistent-with
Any vs. object
1 / 2x: Any lets a type checker
Pick an answer to see if you are right.
Optional and Union types
Optional[str] is shorthand for Union[str, None] — the value may be a str or it may be None. It does not make the parameter optional by itself; you still need = None in the signature for that. Union[A, B] means "either type is acceptable," and is the right tool when a function's return value can genuinely be one of several unrelated types.
🧪 Optional default, and a Union return type
Avoid Union return types when you can — they push a runtime type check onto every caller. parse_token is a reasonable exception: a small expression evaluator that genuinely produces either a number or a leftover token.
Mini quiz — Optional and Union
Optional[X] and Union[X, Y]
1 / 2plural: Optional[str] = None means
Pick an answer to see if you are right.
Challenge: parse token
Return float(token) if it parses; otherwise return token unchanged.
Generic collections
Since Python 3.9, built-in collections accept [] directly: list[str] means "a list of strings," dict[str, set[str]] means "a dict mapping strings to sets of strings." stuff: list and stuff: list[Any] mean exactly the same thing. Return types should stay concrete (list[str], not Sequence[str]) — Postel's law: be liberal in parameter types, conservative in what you return.
🧪 A tokenizer and a first-letter index
Notice index: dict[str, set[str]] = {} — a local variable annotation. Without it, a type checker can't tell what the empty {} is meant to hold, and will ask you to spell it out. The same "accept the abstract type, return the concrete one" logic applies to collections.abc.Mapping vs. dict: annotate a parameter as Mapping so callers can pass a dict, a defaultdict, or a UserDict subclass — dict alone would reject a UserDict, even though it's the recommended way to build a custom mapping.
Mini quiz — generic collections
list[str], dict[K, V]
1 / 3stuff: list and stuff: list[Any]
Pick an answer to see if you are right.
Challenge: first-letter index
Group words by their first letter into dict[str, set[str]].
Tuple types
Three distinct uses need three distinct annotations. A tuple as a fixed record — tuple[str, float, str] for ('Shanghai', 24.28, 'China'). A record with named fields — just use typing.NamedTuple, which is consistent-with the equivalent plain tuple type (so a NamedTuple instance works anywhere that plain-tuple type is expected — but not the reverse). A tuple of unspecified length, uniform type — tuple[int, ...], the literal ... token meaning "one or more of the same type."
🧪 A record, a NamedTuple record, and a variadic tuple
display is annotated to take a plain tuple[float, float], but a Coordinate (a NamedTuple) passes through it without complaint from a type checker or the runtime — that's the "consistent-with" relationship in action.
Mini quiz — tuple annotations
Three tuple flavors
1 / 2tuple[int, ...]
Pick an answer to see if you are right.
Challenge: variadic tuple sum
total(nums: tuple[int, ...]) -> int — sum an unspecified number of ints.
TypeVar and generic functions
A parameterized generic like Sequence[T] links a parameter's element type to the return type: whatever T turns out to be when the function is called, that's what comes back. T = TypeVar('T') is how you introduce that variable name — Python's [] syntax has no other way to declare it.
🧪 first() returns whatever type it was given
An unrestricted TypeVar accepts anything. TypeVar('N', float, Decimal, Fraction) restricts it to exactly those listed types. TypeVar('H', bound=Hashable) bounds it — any type is fine, as long as it's consistent-with Hashable — which is usually the more flexible and maintainable choice.
Mini quiz — TypeVar
Generic functions
1 / 2def first(seq: Sequence[T]) -> T — calling first("hello")
Pick an answer to see if you are right.
Challenge: generic top-N
top(series: Iterable[T], n: int) -> list[T] — the n largest items, sorted descending.
Static protocols
A typing.Protocol subclass declares "any type with these methods counts" — no inheritance, registration, or explicit relationship required. That's static duck typing: it makes the informal "if it quacks like a duck" contract explicit enough for a type checker to verify. Decorate one @runtime_checkable and isinstance() will check it structurally too — purely by "does this object have the method," not by checking any declared relationship.
🧪 isinstance() against a Protocol, structurally
Robot never mentions SupportsQuack anywhere — no base class, no registration — yet isinstance(Robot(), SupportsQuack) is True, purely because it happens to define .quack(). That's the whole point: a Protocol lets pre-existing types, including ones you don't control, satisfy an interface just by shape.
Mini quiz — Protocol
Static duck typing
1 / 2To make a class satisfy a Protocol, it must
Pick an answer to see if you are right.
Challenge: structural quack check
quacks(obj) -> bool returns whether obj satisfies SupportsQuack — no inheritance needed.
Callable, NoReturn, and annotated variadic parameters
Callable[[ParamType1, ParamType2], ReturnType] annotates a function passed as an argument — the list in [] is the parameter types, and ... in place of that list means "any signature." NoReturn marks a function that never returns normally — it always raises. And the *args/**kwargs you met in Chapter 7 get typed too: *content: str means every item collected into content is str, and **attrs: str means attrs ends up as dict[str, str].
🧪 Callable parameter, NoReturn helper, typed variadics
Mini quiz — Callable, NoReturn, typed variadics
Higher-order and never-returning functions
1 / 3Callable[[int, int], int]
Pick an answer to see if you are right.
Challenge: apply an operator
apply_op(a, b, op: Callable[[int, int], int]) -> int just calls op(a, b).
Chapter summary
- Type hints are optional, unenforced at runtime, and give no performance boost — they exist purely for static tools.
- Duck typing (runtime) and nominal typing (static) coexist in a gradual system; a type checker can flag code that runs correctly, and miss code that will crash.
Anyopts an expression out of checking entirely;objectaccepts every value but supports almost no operations.intis consistent-withfloat, which is consistent-withcomplex.Optional[X]meansUnion[X, None]— it does not by itself make a parameter optional; you still need= None.- Generic collections use
[]directly (list[str],dict[str, set[str]]) since Python 3.9. Annotate parameters with abstract types (Mapping,Sequence,Iterable); keep return types concrete. - Tuples get three annotation styles: fixed record (
tuple[str, float]), named record (NamedTuple), and variable-length uniform (tuple[int, ...]). TypeVarlinks a parameter's element type to the return type in a generic function; restrict it to a closed list of types, or bound it to anything consistent-with an ABC likeHashable.typing.Protocol(optionally@runtime_checkable) makes duck typing explicit and checkable — no inheritance required, just matching methods.Callable[[...], R]types a function argument;NoReturnmarks a function that always raises;*args/**kwargscan be typed per-item.- Type hints catch real bugs in large codebases, but they produce false positives and negatives, can't express most business-logic constraints ("quantity must be > 0"), and are never a substitute for automated tests.
Exercises
distance(p: tuple[float, float]) -> float returns the Euclidean distance from the origin. A Point(NamedTuple) instance should work here too, since it's consistent-with tuple[float, float].