Command Palette

Search for a command to run...

Hectal
PHASE 10Intermediate Python 3.10+ ~27 min· topic 2 of 9

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] is str | 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. after if x is None: return, x is no longer None.

Step by step

01Say what is inside

Put the item types in square brackets. A dictionary needs two: key type, then value type.

main.pywhole filepython
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.

main.pywhole filepython
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.

main.pywhole filepython
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 int

04Accept 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.

main.pywhole filepython
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. 1

    Spot the None

    In the second example, remove the if text is None check and call shout(None). Python raises AttributeError: '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. 2

    Widen a parameter

    In the first example, pass a tuple (1, 2, 3) to average. It works, but the hint says list[int]. Change the hint to Sequence[int] (import it from collections.abc) so the label tells the truth.

Code & diagrams

Collection and callable hints, read back New tab

get_type_hints shows the labels exactly as a library or checker sees them.

Sign in to run this example in your browser.

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)
Unions at run time Python 3.10+ New tab

A union is a real object. isinstance accepts it, but not a parameterised generic such as list[int].

Sign in to run this example in your browser.

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'>
Why signatures prefer Iterable and Sequence (and the str trap) New tab

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.

Sign in to run this example in your browser.

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.

main.pywhole filepython
data = [1, 2, 3]
print(isinstance(data, list[int]))
terminal
$ python main.py
── what you'll see ──
Traceback (most recent call last):
File "main.py", line 2, in <module>
print(isinstance(data, list[int]))
~~~~~~~~~~^^^^^^^^^^^^^^^^^
TypeError: isinstance() argument 2 cannot be a parameterized generic

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 returning list[int] tells the caller they may append.

  • ▸

    Use X | None rather than Optional[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") or from __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 match and 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, Mapping values, Iterable, tuple) are covariant: a Sequence[int] is accepted where Sequence[float] is expected. A mutable list[int] is invariant: it is *not* accepted for list[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 be Sequence/Mapping/Iterable, and why mypy's "incompatible type list[int]; expected list[float]" comes with a note suggesting Sequence.

  • ▸

    Optional[X] and X | None are 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 or from __future__ import annotations in annotations only, because an alias such as UserId = int | str is a real run-time expression. Remember a str is an Iterable[str] and a Sequence[str]: a function that expects "a list of names" and receives "asha" is accepted by the checker and then loops over letters, so use list[str] or add a run-time check where that matters.

Remember this

  1. 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 import List, Dict from typing; you will still see that in older code, and it still works, but new code should not use it.

  2. 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. 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 used Union[int, str]. The same | also works at run time in isinstance(x, int | str) (isinstance asks "is this value of this type?", Topic 1.6).

  4. 4

    The most common union is "something or nothing": str | None. Recall from Topic 1.4: None is Python's value for "nothing here". The old spelling was Optional[str]. The name is misleading: it does not mean the parameter may be omitted (that is what a default value does).

  5. 5

    A checker forces you to deal with the None before using the string. This single rule removes the most common crash in Python, 'NoneType' object has no attribute ....

  6. 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 module collections.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. 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. 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

01

How do you write the type of a list of integers, and which Python version made the plain list[int] form possible?

02

What does str | None mean, and how is it different from a parameter with a default value?

03

Why hint a function parameter as Iterable[int] instead of list[int]?

04

What is narrowing?

05

Why does a checker reject passing a list[int] to a function that takes list[float], yet accept it for Sequence[float]?

Practice

01

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 [].

02

Write describe(value: int | str) -> str returning "number 5" or "text hi" using isinstance.

03

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] and Optional[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 explain X | None.

  • Done when you can choose between Iterable, Sequence and Mapping for a parameter.

  • Done when you can hint a function argument with Callable[[A], B].

  • Done when you can explain narrowing with an is None check.