This lesson is about the typing module, the standard library module that fills in everything basic generics don't cover: optional values, multiple allowed types, function signatures as values, and the abstract collection types you should reach for when writing function parameters. We'll also cover the modern | operator, which has quietly replaced most of typing.Union and typing.Optional in idiomatic 3.10+ code.
Before Python 3.9, the built-in collection types like list and dict couldn't be parameterized. If you wanted to say "list of strings", you had to import List from typing and write List[str]. Since 3.9, plain list[str] works, and since 3.10 the X | Y syntax replaces Union[X, Y].
So what's left in typing that you still need? Quite a bit:
| Construct | Purpose | Modern alternative |
|---|---|---|
Optional[X] | Value is either X or None | X | None (3.10+) |
Union[X, Y] | Value is one of several types | X | Y (3.10+) |
Callable[[A, B], R] | A callable with given arg and return types | No built-in replacement |
Iterable[T], Iterator[T] | Anything you can loop over once or step through | No built-in replacement |
Sequence[T], Mapping[K, V] | Read-only list-like or dict-like input | No built-in replacement |
Any, NoReturn, Never | Special types with no built-in equivalent | No built-in replacement |
TypeVar, Generic, Protocol, TypedDict | Building blocks for generic and structural typing | No built-in replacement |
The right mental model: typing is now a module for the things that aren't plain collection generics. The built-in generics handle the common case of "list of products" or "dict of name to price". typing handles everything else.
Each of those typing constructs answers a real question. Optional says "this lookup might miss". Iterable says "I'll accept anything you can loop over, not just a list". The rest of this lesson walks through them one at a time.
Before Python 3.9, parameterizing a collection required importing its capitalized alias from typing:
Every one of those capitalized aliases maps to the equivalent lowercase built-in:
Legacy (typing) | Modern (built-in) | Since |
|---|---|---|
List[T] | list[T] | 3.9 |
Dict[K, V] | dict[K, V] | 3.9 |
Tuple[T1, T2, ...] | tuple[T1, T2, ...] | 3.9 |
Set[T] | set[T] | 3.9 |
FrozenSet[T] | frozenset[T] | 3.9 |
Type[X] | type[X] | 3.9 |
The same function in modern style:
No imports needed. The capitalized aliases are still in typing and still work, mostly because removing them would break years of existing code. PEP 585 (the change that allowed list[int] to work as a type) explicitly says the typing aliases will be deprecated but kept around indefinitely.
When you read old code, you'll see the capitalized forms everywhere. When you write new code on 3.9 or later, use the lowercase built-ins. Mixing both in the same project works fine, but the type checker will sometimes complain if you use the capitalized form in a brand-new file.
Note: Python 3.7 and 3.8 still require from __future__ import annotations to use list[int] as a runtime annotation. If you're stuck on 3.8, the capitalized aliases are your only option without the future import. Anything 3.9+ supports both styles freely.
Optional[X] means "either an X or None". It's the single most useful construct in typing because the None case shows up everywhere: lookups that miss, fields that aren't set yet, parameters that default to None, return values that mean "not found".
The return type Optional[str] is exactly equivalent to Union[str, None]. The Optional form exists because the "value or None" pattern is so common that it earned its own shorthand.
A second classic use case is parameters that default to None:
The note parameter accepts a string or None. The default is None, which means "no note provided". Without Optional, a checker would flag the default value because None isn't a str.
One common mistake worth flagging: Optional[X] does not mean "this parameter is optional in the function-call sense". It only describes the value's type. A parameter that has a default value is optional to the caller; a parameter typed Optional[X] is one whose value might be None. These are independent concepts that happen to share a word.
Here customer_id has no default, so the caller must pass an argument. That argument can be an int or None. The function signature documents both halves of that statement.
Union[X, Y] says the value is one of several types. The classic case is a value that comes in two representations and you handle both.
The discount parameter accepts either an int (interpreted as cents) or a float (interpreted as a fraction). The function branches on the actual type at runtime. The type checker knows that inside the if isinstance(discount, int) block, discount is narrowed to int, and in the else branch it's narrowed to float. This is called type narrowing, and it's one of the main reasons static type checkers exist.
Union accepts more than two types:
Three branches in the union, three branches in the function. The pattern of "isinstance-check then handle each case" is how you safely use a Union parameter.
One useful fact: Union[X, X] collapses to X, and Union[X, None] is the same as Optional[X]. The order also doesn't matter; Union[int, str] and Union[str, int] are the same type.
Note: A function returning Union[int, str] is harder for callers to use than one returning just int or just str. Every caller has to check which type they got. Use unions when the two representations genuinely matter to the caller, not as a shortcut around picking a single return type.
Python 3.10 introduced the | operator as a shorthand for Union. It works directly on types, no import needed.
The two forms are exactly equivalent. The | form has three practical advantages:
int | float works in any file without touching typing.str or None" maps cleanly to str | None in a way Optional[str] doesn't quite match.int | str | bytes is shorter than Union[int, str, bytes].In modern Python codebases, | is the default. Reach for Optional and Union from typing only when you have to support Python 3.9 or older.
Same behavior, two style choices. If your team's minimum supported version is 3.10 or later, prefer the pipe. If you need to ship libraries that still support 3.8 or 3.9 at runtime, use Optional and Union.
The decision is mechanical: if your target Python version is 3.10 or higher, the pipe wins on every axis. Otherwise the typing-module forms still work.
A function in Python is a value, like any other. You can pass it as an argument, store it in a list, or return it from another function. To type-hint these "function-typed" values, you use Callable from typing.
Callable[[arg_types], return_type] is the form. The arg types go in a bracketed list (even if there's only one), and the return type comes after the closing inner bracket.
The transform parameter is any function that takes a float and returns a float. The three calls pass three different callables: a named function, another named function, and a lambda. All three match the signature.
A more interesting case: building a discount strategy where the caller supplies the rule.
Each rule has the same shape, float -> float, so any of them slots into the discount_rule parameter. The type checker enforces the shape across all three call sites. If someone tried to pass bulk_discount as a Callable[[str], float] somewhere else, the checker would catch it.
A few signature variations worth knowing:
| Annotation | Meaning |
|---|---|
Callable[[], int] | No arguments, returns int |
Callable[[float], float] | One float argument, returns float |
Callable[[str, int], bool] | Two arguments, returns bool |
Callable[..., float] | Any arguments, returns float (when you don't care about arg types) |
Callable[[float], None] | Takes a float, returns nothing useful (procedure-style) |
The ... form is an escape hatch. It says "this is callable, but I don't want to constrain how it's called". Use it sparingly; you lose most of the safety the annotation was supposed to provide.
The Callable[..., None] annotation accepts callables with any signature that returns None. Useful for generic dispatch, less useful when you want the checker to verify the call sites match.
Note: Callable checks the call signature, not whether the value is literally a function. Anything with a compatible __call__ method, including a class instance with __call__ defined, satisfies a Callable annotation. The checker doesn't care if you wrote def, lambda, or a class; it only cares about the call shape.
When you write a function parameter typed list[float], the checker rejects anything that isn't a list. Pass a tuple, generator, or NumPy array, and the checker complains, even if your function only iterates the input and never calls list-specific methods.
The fix is to type your parameters by the abstract capability you need, not the concrete type. The typing module exports a set of abstract types that describe what an object can do, not what it is:
| Annotation | Means the value supports... | Example types that match |
|---|---|---|
Iterable[T] | Being looped over once (for x in obj) | list, tuple, set, generator, dict (keys), file |
Iterator[T] | Being stepped through with next() | generator, iter(some_list) |
Sequence[T] | Indexing (obj[i]), len(), iteration | list, tuple, str, range |
Mapping[K, V] | obj[k], k in obj, .keys(), .values() | dict, types.MappingProxyType |
MutableMapping[K, V] | Mapping plus obj[k] = v, del obj[k] | dict |
MutableSequence[T] | Sequence plus .append, .remove, etc. | list |
The rule of thumb: for function inputs, use the most abstract type that still gives you everything you need. For function outputs, use the concrete type.
Each function accepts a wider range of inputs than its list/dict counterpart would. cart_total doesn't care whether the caller has a list, a tuple, or a generator. The function only iterates, and Iterable documents exactly that.
The contrast with the concrete-type version is worth seeing:
The function runs fine because sum() doesn't care about the container, but a static checker treats the call as a type error. The list[float] annotation is a stricter promise than the function actually needs.
A second example, with a function that processes orders. It only needs to read the order ID and the items, never mutate the input:
The function reads keys from a Mapping. It doesn't insert, delete, or replace anything, so Mapping is correct and MutableMapping would over-promise.
The same principle for outputs, in reverse: when you return a value, be concrete.
The input is Iterable[float] so callers can pass any iterable. The return is list[float] because that's what the function actually builds, and the caller can rely on list-specific behavior like indexing or appending. Returning Iterable[float] here would force callers to convert the result themselves.
This "accept widely, return narrowly" pattern is sometimes called the robustness principle of API design. The function is generous with what it accepts and specific about what it produces.
One gotcha with Iterable: a generator can be iterated only once. If your function iterates the input twice, Iterable is technically the wrong type. Use Sequence (which can be re-iterated) or convert to a list inside the function.
The first sum(prices) exhausts the generator. The second pass sees zero items. The fix is either to require Sequence, which guarantees multi-pass iteration, or materialize the input:
The conversion is a tradeoff: you accept any iterable but pay the cost of materializing it. Pick the type that matches what your function actually does.
Cost: Materializing an Iterable into a list with list(prices) is O(n) in both time and memory. For a small cart this is free. For a million-row report you might want Sequence (so callers must pass something re-iterable, but you don't allocate) or two parameters (count and a fresh iterator each time).
10 quizzes