Topic 10.2
Typing Collections, Optional and Unions
In one line
Hints for containers say what is inside: list[int], dict[str, int], tuple[int, str]. X | None means "an X, or nothing", and Sequence, Mapping, Iterable and Callable describe what a function needs without demanding one exact class.
Think of it like this
A lunchbox with labelled compartments. "Fruit" is a loose label; "three apples" is exact. list says "a list"; list[int] says "a list of whole numbers". The more exact label lets a checker catch the day somebody packs a sandwich in the fruit compartment.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Argument
- The actual value you pass when calling a function. The name that receives it inside the function is the parameter (Topic 5.1).
- Generic container
- A container whose hint also names what it holds, like
list[int]. - Union
- A hint meaning "one of these types", written
A | B. - Optional
- Another way to say "this type or None".
Optional[str]isstr | None. - Iterable
- Anything you can loop over with
for: lists, tuples, strings, generators. - Sequence
- An ordered, indexable collection with a length: list, tuple, str, range.
- Mapping
- A read-only key-to-value collection such as a dict.
- Callable
- Anything you can call with brackets: a function, a lambda, a class.
- Narrowing
- When a checker works out a more exact type inside an
if, e.g. afterif x is None: return,xis no longerNone.
Step by step
01Say what is inside
Put the item types in square brackets. A dictionary needs two: key type, then value type.
scores: list[int] = [90, 82, 77]
prices: dict[str, float] = {"tea": 20.0, "milk": 30.5}
unique_ids: set[int] = {1, 2, 3}
point: tuple[int, int] = (3, 4)
anything_int: tuple[int, ...] = (1, 2, 3, 4)02Or, with |
int | str is a value that is an int or a str. It is a real object at run time: you can print it, pass it around, and use it in isinstance. Union[int, str] is the older spelling and the two compare equal.
UserId = int | str
def find(user_id: UserId) -> str:
return f"user {user_id}"03None and narrowing
dict.get returns the value or None, so a function that returns it is int | None. A checker will refuse result + 1 until you handle None. After an if result is None: ... branch that returns or raises, the checker knows that below that line result is a plain int. This is called narrowing.
def find_user(users: dict[str, int], name: str) -> int | None:
return users.get(name)
user_id = find_user({"asha": 1}, "asha")
if user_id is None:
print("no such user")
else:
print(user_id + 1) # here user_id is an int04Accept general types, return specific ones
If a function only loops, accept Iterable[int]. Then it works for lists, tuples, range and generators. A parameter hinted list[int] rejects a tuple, for no good reason. The return type, on the other hand, should say exactly what you give back so callers know what they can do with it.
from collections.abc import Callable, Iterable
def apply(values: Iterable[int], fn: Callable[[int], int]) -> list[int]:
return [fn(v) for v in values]
print(apply(range(3), lambda n: n * 10))05Reading hints at run time
typing.get_type_hints(fn) returns a function's hints with names resolved. typing.get_origin and get_args take a hint apart: get_origin(list[int]) is list and get_args(list[int]) is (int,). Libraries use these to understand your labels.
06Watch out: a hint is not a check
isinstance(x, list[int]) raises TypeError, because Python cannot cheaply check every item of a list. Use isinstance(x, list). To check the items you must loop over them yourself, which is what validators such as Pydantic do.
Try it yourself
- 1
Spot the None
In the second example, remove the
if text is Nonecheck and callshout(None). Python raisesAttributeError: 'NoneType' object has no attribute 'upper'. A type checker would have flagged that line before you ran anything (Topic 10.3 shows the message). - 2
Widen a parameter
In the first example, pass a tuple
(1, 2, 3)toaverage. It works, but the hint sayslist[int]. Change the hint toSequence[int](import it fromcollections.abc) so the label tells the truth.
Code & diagrams
get_type_hints shows the labels exactly as a library or checker sees them.
Expected output
average {'scores': list[int], 'return': <class 'float'>}
find_user {'users': dict[str, int], 'name': <class 'str'>, 'return': int | None}
old_style {'name': typing.Optional[str], 'return': <class 'str'>}
apply {'values': collections.abc.Iterable[int], 'fn': collections.abc.Callable[[int], int], 'return': list[int]}
total {'prices': collections.abc.Mapping[str, float], 'skip': collections.abc.Sequence[str], 'return': <class 'float'>}
1 None
[0, 10, 20]
20.0
(3, 4) (1, 2, 3, 4)A union is a real object. isinstance accepts it, but not a parameterised generic such as list[int].
Expected output
Id is int | str
args: (<class 'int'>, <class 'str'>)
Union[int, str] == int | str -> True
origin of list[int]: <class 'list'> (<class 'int'>,)
True True False
TypeError: isinstance() argument 2 cannot be a parameterized generic
HI (nothing)
type(None): <class 'NoneType'>A tag counter for blog posts. A checker accepts the string in the second call, because a str is an Iterable of one-letter strs. The last call shows why a function that changes its list must say list[float]: the checker refuses to pass it a list[int] (a list is invariant), while read-only Sequence[float] accepts one.
Expected output
{'python': 2, 'ai': 1}
{'p': 1, 'y': 1, 't': 1, 'h': 1, 'o': 1, 'n': 1}
85.0
[80, 90, 100.0]Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
isinstance with a parameterised generic
Try to check a list's item type with isinstance.
data = [1, 2, 3]
print(isinstance(data, list[int]))Myth vs fact
Myth
Optional[str] means the argument can be left out.
Fact
It only means the value may be None. A parameter that can be omitted needs a default value (name: str | None = None).
Myth
I must import List and Dict from typing.
Fact
Not since Python 3.9: write list[int] and dict[str, int] directly. The capitalised versions are old style.
Myth
list[int] makes Python reject lists with strings in them.
Fact
It does not. Python never checks. Only a type checker or a validation library does.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Prefer abstract types in parameters and concrete types in returns. A function accepting
Mapping[str, int]can be tested with a plain dict and lets callers pass read-only mappings; a function returninglist[int]tells the caller they may append. - ▸
Use
X | Nonerather thanOptional[X]in new code, and keep unions short. A union with six members usually means the design wants a class or a tagged type (Topic 10.8). - ▸
For hints that mention a class defined later in the file, use quotes (
"Node") orfrom __future__ import annotations. On Python 3.14 annotations are evaluated lazily so the quotes are no longer needed (Topic 10.7). - ▸
Hints on messages going to and from an LLM API are usually unions: a content block may be text or a tool call. Modelling them as typed unions is what lets
matchand the checker cover every case (Topic 10.8). - ▸
Variance is the rule for whether a container of a subtype may stand in for a container of its supertype. Read-only containers (
Sequence,Mappingvalues,Iterable,tuple) are covariant: aSequence[int]is accepted whereSequence[float]is expected. A mutablelist[int]is invariant: it is *not* accepted forlist[float], because the callee could append a float and break the caller's promise that the list holds ints.dict[str, int]is likewise invariant in its value type. This is why parameters that only read should beSequence/Mapping/Iterable, and why mypy's "incompatible typelist[int]; expectedlist[float]" comes with a note suggestingSequence. - ▸
Optional[X]andX | Noneare the same type to every checker (they compare equal at run time too). The|spelling needs Python 3.10 when it is evaluated; on 3.9 use quotes orfrom __future__ import annotationsin annotations only, because an alias such asUserId = int | stris a real run-time expression. Remember astris anIterable[str]and aSequence[str]: a function that expects "a list of names" and receives"asha"is accepted by the checker and then loops over letters, so uselist[str]or add a run-time check where that matters.
Remember this
- 1
Since Python 3.9 the ordinary container classes take their item types in square brackets:
list[int],set[str],dict[str, float](a dictionary from text keys to decimal values). Before 3.9 you had to importList,Dictfromtyping; you will still see that in older code, and it still works, but new code should not use it. - 2
Tuples are special because they are often fixed-length with different types per position.
tuple[int, str]means exactly two items, an int then a str.tuple[int, ...](with the literal three dots) means any number of ints. - 3
Many values can be "one of several types". A union writes this with
|:int | str. Since Python 3.10 you can write it with|directly; older code usedUnion[int, str]. The same|also works at run time inisinstance(x, int | str)(isinstanceasks "is this value of this type?", Topic 1.6). - 4
The most common union is "something or nothing":
str | None. Recall from Topic 1.4:Noneis Python's value for "nothing here". The old spelling wasOptional[str]. The name is misleading: it does not mean the parameter may be omitted (that is what a default value does). - 5
A checker forces you to deal with the
Nonebefore using the string. This single rule removes the most common crash in Python,'NoneType' object has no attribute .... - 6
Function parameters should usually describe what the function needs, not one specific class. If a function only loops over its input, the right hint is
Iterable[int](anything you can loop over). If it needs indexing and length,Sequence[int]. If it only reads key-value pairs,Mapping[str, int]. These live in the standard modulecollections.abc(Topic 6.1 shows how to import). Each one accepts every type that behaves that way: lists, tuples, ranges, generators, dicts and your own classes. - 7
Recall from Topic 5.6: a function can be passed to another function like any value. Such a parameter is hinted with
Callable[[int, str], bool]: something you can call with an int and a str, giving back a bool. The first item is the list of parameter types; the last is the return type. - 8
A good rule: be generous in what you accept and specific in what you give back. Parameters are better as general as possible (
Iterable[int]). Return types are better as exact as possible (list[int]).
Explain it without notes
How do you write the type of a list of integers, and which Python version made the plain list[int] form possible?
What does str | None mean, and how is it different from a parameter with a default value?
Why hint a function parameter as Iterable[int] instead of list[int]?
What is narrowing?
Why does a checker reject passing a list[int] to a function that takes list[float], yet accept it for Sequence[float]?
Practice
Write first_or_none(items: list[int]) -> int | None that returns the first item or None for an empty list. Print the result for [7, 8] and [].
Write describe(value: int | str) -> str returning "number 5" or "text hi" using isinstance.
Write call_twice(fn: Callable[[int], int], x: int) -> int that returns fn(fn(x)) and call it with a lambda that adds 3 to 10.
Trade-offs
- ↔
General parameters, specific returns make functions flexible for callers and informative for readers, at the cost of thinking about which abstract type fits.
- ↔
Unions model real variety but each extra member forces every caller to handle another case. Keep them small.
- ↔
Old and new spellings coexist.
List[int]andOptional[int]still work in every supported version, so a codebase can modernise gradually (a tool such as Ruff can rewrite them automatically).
Done when you can
Done when you can hint lists, dicts, sets and tuples with their item types.
Done when you can write a union with
|and explainX | None.Done when you can choose between
Iterable,SequenceandMappingfor a parameter.Done when you can hint a function argument with
Callable[[A], B].Done when you can explain narrowing with an
is Nonecheck.