No history yet

Nested Generic Collections

Handling Complex Data

You're comfortable annotating simple collections like list[int] or dict[str, str]. But real-world data is rarely that clean. Often, you'll work with data structures nested inside one another, like lists of dictionaries, which is common when fetching data from a JSON API.

Before Python 3.9, you needed to import List and Dict from the typing module. Now, you can use the built-in list and dict types directly, making the syntax cleaner and more intuitive. Let's look at how to annotate a list where each item is another list of integers.

matrix: list[list[int]] = [
    [1, 2, 3],
    [4, 5, 6],
    [7, 8, 9]
]

The outer list indicates the primary collection, and the inner list[int] specifies that each element inside the outer list is, itself, a list of integers. This nesting can go as deep as needed.

A more practical example involves a list of dictionaries. Imagine you have data representing a list of users, where each user is a dictionary.

users: list[dict[str, int | str]] = [
    {"user_id": 101, "username": "alice"},
    {"user_id": 102, "username": "bob"}
]

Here, users is a list. Each item in the list is a dictionary (dict) that must have string keys. The values in the dictionary can be either an integer or a string, which we denote with the union type int | str.

Simplifying with Type Aliases

As type hints get more complex, they can become unwieldy and hard to read. Repeating list[dict[str, int | str]] throughout your code is not ideal. To solve this, you can create a type alias—a custom name for a type annotation.

Using a type alias makes your code more readable and easier to maintain. You define the complex type once and reuse the alias everywhere else.

from typing import TypeAlias

# Define a custom type alias for a user dictionary
User = dict[str, int | str]

# Use the alias in your function annotation
def process_users(users: list[User]) -> None:
    for user in users:
        print(f"Processing user: {user['username']}")

process_users([
    {"user_id": 101, "username": "alice"},
    {"user_id": 102, "username": "bob"}
])

By defining User as an alias for our dictionary structure, the process_users function signature becomes much clearer. Anyone reading it immediately understands it expects a list of User objects.

Creating Generic Collections

Sometimes you need to write a function that can operate on collections of different types, but you still want to maintain type safety. For example, a function that gets the first item from any non-empty list, no matter what the list contains. This is where TypeVar comes in.

TypeVar

other

A type variable that lets you link several types together, allowing for the creation of generic functions and classes.

A TypeVar acts as a placeholder for a type. When you define a function with a TypeVar, a static type checker understands that whatever type is passed in for one argument must be the same for another argument or the return value.

from typing import TypeVar, Sequence

# Create a TypeVar named 'T'. It can be any type.
T = TypeVar('T')

def get_first(items: Sequence[T]) -> T:
    """Returns the first item of any sequence."""
    if not items:
        raise ValueError("Sequence cannot be empty")
    return items[0]

# Mypy will infer the type correctly
first_num = get_first([1, 2, 3])      # Inferred type of first_num is int
first_str = get_first(("a", "b", "c"))  # Inferred type of first_str is str

In this example, T is our type variable. We've defined get_first to accept a Sequence[T] (a generic way to represent lists, tuples, etc.) and return a value of type T. When we call get_first([1, 2, 3]), the type checker sees that T is int, so it knows the function will return an int. Similarly, for get_first(("a", "b", "c")), T becomes str.

Now, let's check your understanding of these concepts.

Quiz Questions 1/5

How would you correctly annotate a variable user_data that holds a list of dictionaries, where each dictionary represents a user with a string name and an integer id?

Quiz Questions 2/5

What is the primary benefit of creating a type alias like User = dict[str, int | str]?

Mastering nested type hints allows you to accurately describe complex data structures, making your code safer and easier for others to understand.