Optional, Union and Generic Types
18 min · Intermediate
Need help? Ask a tutor on WhatsApp (opens in a new tab)Real data is rarely one plain type. A lookup may find nothing, and a function may accept text or a number. Type hints can say all of this in a short form.
Containers: list[int] and dict[str, float]
Generic type is a type that takes other types in square brackets, like list[int]. The brackets say what the container holds, so a checker can tell list[int] from list[str].
A bare list does not say what it holds. Put the item types in square brackets after the container name.
marks: list[int] = [70, 85]prices: dict[str, float] = {"pen": 12.5}point: tuple[int, int] = (3, 4)tags: set[str] = {"new"}Example 1 · Hinting containers
def total(prices: dict[str, float]) -> float: return sum(prices.values())
cart: dict[str, float] = {"pen": 12.5, "book": 240.0}print(total(cart))252.5
What this code does
dict[str, float]says keys are strings and values are floats.- A checker would reject a dictionary whose values are strings.
tuple[int, int] means exactly two ints. For any length, write tuple[int, ...]. Since Python 3.9 these built-in names work directly, so the old typing.List is not needed.
None and X | None
Optional type is a type that may also be None. Write it as X | None. The older form Optional[X] means exactly the same thing.
A lookup may find nothing and return None. The hint must say so. Write the real type, then | None.
def find_user(user_id: int) -> str | None: users = {1: "Priya", 2: "Arjun"} return users.get(user_id)Example 2 · Handling None
def find_user(user_id: int) -> str | None: users = {1: "Priya", 2: "Arjun"} return users.get(user_id)
for uid in (1, 3): name = find_user(uid) if name is not None: print(name.upper()) else: print("no such user")PRIYA no such user
What this code does
name.upper()is only called whennameis notNone.- Without that check, mypy reports that
Nonehas no attributeupper.
The caller must now handle None before using the name as a string. A checker enforces that. Optional[str] from typing means the same as str | None.
Union: one of several types
Union type says a value can be one of several types, written A | B. A check such as isinstance narrows it to one type inside that branch.
Sometimes a value may arrive as an int or as a string. Join the types with |. Inside the function, an isinstance check tells the checker which one it has.
def to_amount(value: int | str) -> int: if isinstance(value, str): return int(value) return valueExample 3 · Narrowing a union
def to_amount(value: int | str) -> int: if isinstance(value, str): return int(value) return value
print(to_amount("250") + to_amount(50))300
What this code does
- The
isinstancebranch handles text. The last line only sees anint. - Both branches return an
int, so the declared return type holds.
After the if, the checker knows value is a str. After the return, only int is left. This step is called narrowing.
Your own generic functions
Type variable is a placeholder such as T in def first[T](...). It says the same type goes in and comes out, whatever that type is.
A function like first works for any list. A type variable stands for "whatever type this is", and ties the input type to the output type.
def first[T](items: list[T]) -> T: return items[0]
print(first([3, 5]))print(first(["a", "b"]))3 a
Example 4 · A generic function with a default
def first_or[T](items: list[T], default: T) -> T: return items[0] if items else default
print(first_or([4, 9], 0))print(first_or([], "none"))4 none
What this code does
Tis anintin the first call and astrin the second.- The default must have the same type as the list items.
The brackets after the name declare T. For a list of ints the result is an int, and for a list of strings it is a str. Python 3.12 added this syntax. Older code writes T = TypeVar("T").
Callable and type aliases
Type alias is a short name for a longer type, made with the type statement, such as type Prices = dict[str, float]. It adds no new behaviour.
Functions can be arguments. Callable[[float], float] describes a function that takes a float and returns a float. A type statement gives a long hint a short name.
from collections.abc import Callable
type Prices = dict[str, float]
def apply(rule: Callable[[float], float], prices: Prices) -> Prices: return {k: rule(v) for k, v in prices.items()}Example 5 · Callable and an alias
from collections.abc import Callable
type Prices = dict[str, float]
def apply(rule: Callable[[float], float], prices: Prices) -> Prices: return {k: rule(v) for k, v in prices.items()}
print(apply(lambda p: p * 0.9, {"pen": 10.0, "book": 200.0})){'pen': 9.0, 'book': 180.0}What this code does
ruleis any function from float to float. Here it is a lambda that takes 10 percent off.Pricesis only a name for the dictionary type. Behaviour is unchanged.
The first list in Callable holds the parameter types, and the last item is the return type. Any turns checking off for a value, so use it rarely.
Next step
Check your understanding
12 quick questions