AlgoMaster Logo

typing Module (List, Dict, Optional, Union)

Medium Priority18 min readUpdated September 21, 2026
Listen to this chapter
Unlock Audio

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.

What the typing Module Gives You

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:

ConstructPurposeModern alternative
Optional[X]Value is either X or NoneX | None (3.10+)
Union[X, Y]Value is one of several typesX | Y (3.10+)
Callable[[A, B], R]A callable with given arg and return typesNo built-in replacement
Iterable[T], Iterator[T]Anything you can loop over once or step throughNo built-in replacement
Sequence[T], Mapping[K, V]Read-only list-like or dict-like inputNo built-in replacement
Any, NoReturn, NeverSpecial types with no built-in equivalentNo built-in replacement
TypeVar, Generic, Protocol, TypedDictBuilding blocks for generic and structural typingNo 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.

Legacy Generic Aliases (List, Dict, Tuple)

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.

Optional for Values That May Be None

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 for Multiple Allowed Types

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.

The | Operator (Python 3.10+)

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:

  1. No import. int | float works in any file without touching typing.
  2. Reads naturally. "A str or None" maps cleanly to str | None in a way Optional[str] doesn't quite match.
  3. Composes cleanly. 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.

Callable for Function Types

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:

AnnotationMeaning
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.

Abstract Collection Types (Iterable, Sequence, Mapping)

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:

AnnotationMeans 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(), iterationlist, 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.

Quiz

typing Module Quiz

10 quizzes