Generic Types — List, Dict, Optional, Union and Literal

Simple type hints like str and int cover basic values, but real applications work with nested structures: lists of dicts, optional fields, functions as arguments, and values that could be one of several types. Python’s typing module provides generic types for expressing these complex structures precisely. In FastAPI, precise type hints directly control the API’s OpenAPI schema — list[str] generates a JSON array of strings in the docs, str | None marks a field as optional, and Literal["draft", "published"] generates an enum dropdown. Getting these right means getting accurate, useful API documentation for free.

Optional, Union and Literal

from typing import Optional, Union, Literal
from pydantic import BaseModel

# ── Optional — the parameter can be None ──────────────────────────────────────
def find_user(email: str | None = None) -> dict | None:
    if email is None:
        return None
    return {"email": email}

# Optional[X] is exactly equivalent to X | None
# Both mean: "this can be X or it can be None"

# ── Union — accept multiple types ─────────────────────────────────────────────
def format_value(value: int | float | str) -> str:
    return str(value)

# ── Literal — only specific values are valid ──────────────────────────────────
def set_status(status: Literal["draft", "published", "archived"]) -> None:
    print(f"Setting status to: {status}")

set_status("draft")       # ✓
set_status("published")   # ✓
# set_status("deleted")   # mypy error — not a valid Literal value

# Literal in Pydantic/FastAPI — generates an enum in the API schema
class PostCreate(BaseModel):
    title:  str
    status: Literal["draft", "published"] = "draft"

# ── None as a default vs Optional ────────────────────────────────────────────
def good(name: str | None = None): ...    # Optional str with None default
def also_good(name: str | None): ...      # Optional str, no default (required but nullable)
def required(name: str): ...              # Required string, cannot be None
Note: Optional[X] is not the same as “optional parameter” (parameter with a default value). It means “the value can be X or None.” A parameter can be both: name: str | None = None means the parameter has a default (optional to pass) and its value may be None. A parameter can also be required but nullable: name: str | None with no default means you must pass the argument, but it can be None. FastAPI distinguishes these in the API schema — the first is optional in the request, the second is required but allows null.
Tip: Use Literal instead of plain str for fields that only accept a fixed set of values — status fields, role names, sort directions, format specifiers. In FastAPI, Literal["asc", "desc"] on a query parameter generates a dropdown selector in Swagger UI and validates that the client sent one of the allowed values. This is far more useful than str which accepts any string and requires manual validation.
Warning: Avoid Union[str, int] for API-facing types in FastAPI — JSON does not distinguish between numeric strings and numbers in all contexts, and allowing both types can lead to inconsistent behaviour. Reserve Union types for internal code where both types are genuinely valid. For API boundaries, prefer a single concrete type and let Pydantic coerce values (e.g., int will coerce the string "42" to the integer 42 when received from a query parameter).

Generic Collection Types

from typing import TypeVar, Generic
from pydantic import BaseModel

# ── List, Dict, Set, Tuple ─────────────────────────────────────────────────────
tags:       list[str]            = ["python", "fastapi"]
scores:     dict[str, int]       = {"alice": 95, "bob": 87}
unique_ids: set[int]             = {1, 2, 3}
point:      tuple[float, float]  = (3.14, 2.71)

# Variable-length tuple — all elements same type
coordinates: tuple[float, ...]   = (1.0, 2.0, 3.0, 4.0)

# ── Nested generics ────────────────────────────────────────────────────────────
# List of dicts
users: list[dict[str, str]] = [{"name": "Alice", "email": "alice@example.com"}]

# Dict of lists
tag_posts: dict[str, list[int]] = {"python": [1, 3, 5], "fastapi": [2, 4]}

# FastAPI response model with nested type
class PaginatedResponse(BaseModel):
    items:     list[dict[str, str | int]]
    total:     int
    page:      int
    page_size: int

# ── TypeVar — for generic functions ───────────────────────────────────────────
T = TypeVar("T")

def first(items: list[T]) -> T | None:
    """Return the first item or None."""
    return items[0] if items else None

# mypy knows the return type matches the input type
result = first([1, 2, 3])   # mypy infers: int | None
name   = first(["Alice"])   # mypy infers: str | None

# ── Generic class ──────────────────────────────────────────────────────────────
class Page(BaseModel, Generic[T]):
    """Generic paginated response — Page[PostResponse], Page[UserResponse], etc."""
    items:     list[T]
    total:     int
    page:      int
    page_size: int

# Use in FastAPI
@app.get("/posts", response_model=Page[PostResponse])
async def get_posts(): ...

Callable, Sequence and Other typing Types

from typing import Callable, Sequence, Mapping, Any, TypedDict

# ── Callable — a function as a type ───────────────────────────────────────────
# Callable[[arg_types...], return_type]
def apply(func: Callable[[int], str], value: int) -> str:
    return func(value)

apply(str, 42)              # ✓ str takes int, returns str
apply(lambda x: f"#{x}", 42)   # ✓ lambda matches signature

# No-arg function returning bool
def run_check(checker: Callable[[], bool]) -> bool:
    return checker()

# ── Sequence — read-only ordered sequence (list or tuple) ────────────────────
def total(numbers: Sequence[int]) -> int:
    return sum(numbers)

total([1, 2, 3])       # ✓ list
total((1, 2, 3))       # ✓ tuple
total({1, 2, 3})       # mypy error — set is not a Sequence (no order guarantee)

# ── Mapping — read-only dict-like ─────────────────────────────────────────────
def extract_names(data: Mapping[str, dict]) -> list[str]:
    return [user["name"] for user in data.values()]

# ── TypedDict — dict with typed keys ─────────────────────────────────────────
class UserDict(TypedDict):
    id:    int
    name:  str
    email: str

def process_user(user: UserDict) -> str:
    return f"{user['name']} <{user['email']}>"

user: UserDict = {"id": 1, "name": "Alice", "email": "alice@example.com"}
process_user(user)   # ✓ type-checked

# ── Any — opt out of type checking ───────────────────────────────────────────
def legacy_process(data: Any) -> Any:
    # Any accepts everything — use sparingly
    return data

Common Mistakes

Mistake 1 — Confusing Optional with “has a default value”

❌ Wrong — assuming Optional means the parameter can be omitted:

def get_user(user_id: int | None):   # still REQUIRED — just allows None value
    ...

get_user()   # TypeError — missing required argument

✅ Correct — add a default to make it truly optional:

def get_user(user_id: int | None = None):   # ✓ now it can be omitted
    ...

Mistake 2 — Using List (capital L) in Python 3.9+ code

❌ Wrong — importing List from typing when not needed:

from typing import List, Dict   # unnecessary in Python 3.9+
def f(items: List[str]) -> Dict[str, int]: ...

✅ Correct — use built-in types directly:

def f(items: list[str]) -> dict[str, int]: ...   # ✓ Python 3.9+

Mistake 3 — Using Any to avoid thinking about types

❌ Wrong — Any disables all type checking:

def process(data: Any) -> Any:   # no type information — mypy cannot help here
    return data["name"].upper()

✅ Correct — use TypedDict or a Pydantic model:

class UserData(TypedDict):
    name: str

def process(data: UserData) -> str:   # ✓ mypy knows data has .name
    return data["name"].upper()

Quick Reference

Type Meaning Example
X | None X or None name: str | None
X | Y X or Y val: int | float
list[X] List of X tags: list[str]
dict[K, V] Dict with key K, value V scores: dict[str, int]
tuple[A, B] Exact-length tuple point: tuple[float, float]
Literal[a, b] One of specific values status: Literal["on", "off"]
Callable[[A], R] Function taking A, returning R fn: Callable[[int], str]
TypeVar("T") Generic type variable def f(x: list[T]) -> T
TypedDict Dict with typed keys Class inheriting TypedDict
Any Opt out of type checking Use sparingly

🧠 Test Yourself

In a FastAPI route, you have sort: Literal["asc", "desc"] = "asc" as a query parameter. What advantages does Literal provide over plain str?