Generics and Type Parameters

Updated

September 8, 2026

Generics and Type Parameters

A generic is a function or class that keeps an element type through the call. first on a list of tables returns a table, not object. In 3.14 you write that with a type parameter list: def first[T](items: list[T]) -> T | None. The boring default is to genericize one helper that is truly reused. A function that only ever sees ticket ids stays int.

Mental model

T is a placeholder. At a call site the checker binds it:

  • first([12, 4])T is int → return int | None
  • first(["open", "mid"])T is str → return str | None

You do not pass T at runtime. first([12, 4]) is ordinary Python.

PEP 695 (the [T] syntax) is the 3.12+ spelling. TypeVar is the older one:

from typing import TypeVar

T = TypeVar("T")

def first(items: list[T]) -> T | None:
    ...

Use TypeVar when you need a bound or a constraint the new syntax does not cover in your checker, or when you are editing a file that already uses it. New desk code: [T].

Do not genericize because it looks like a library. If every caller passes list[int], the signature is list[int].

Worked examples

Case 1: first[T]

Save as first_item.py. Empty list → None. Otherwise the first element, same type as the list.

# first_item.py
def first[T](items: list[T]) -> T | None:
    if not items:
        return None
    return items[0]


def main() -> None:
    print(first([12, 4, 8]))
    print(first(["open", "mid"]))
    print(first([]))


if __name__ == "__main__":
    main()

Run:

uv run python first_item.py

Output:

12
open
None

first([]) has nothing to bind T from. The return is None. Checkers may infer T as Never or Unknown for that call; you still handle None.

Case 2: a small generic class

Save as desk_stack.py. The stack stores one kind of thing. DeskStack[int] is a stack of table numbers.

# desk_stack.py
class DeskStack[T]:
    def __init__(self) -> None:
        self._items: list[T] = []

    def push(self, item: T) -> None:
        self._items.append(item)

    def pop(self) -> T | None:
        if not self._items:
            return None
        return self._items.pop()


def main() -> None:
    tables = DeskStack[int]()
    tables.push(12)
    tables.push(4)
    print(tables.pop())
    print(tables.pop())
    print(tables.pop())


if __name__ == "__main__":
    main()

Run:

uv run python desk_stack.py

Output:

4
12
None

DeskStack[int]() is a runtime subscript in 3.14 (it works). A checker uses it to type push and pop. tables.push("12") is a checker error; at runtime the list would happily store the string. Same lesson as the rest of typing: the hint is the contract, not a guard.

If you only ever stack tickets, write DeskStack with int and skip [T].

Case 3: when not to genericize

Save as ticket_title.py. The id is an int. Wrapping this in [T] would let title("seven") type-check. That is a worse contract.

# ticket_title.py
def title(ticket_id: int) -> str:
    return f"ticket {ticket_id}"


def main() -> None:
    print(title(7))


if __name__ == "__main__":
    main()

Run:

uv run python ticket_title.py

Output:

ticket 7

Ask: “will a second element type show up?” If no, keep the concrete type. Aliases (type TicketId = int) are for meaning. Generics are for reuse across types.

The trap

A generic that accepts everything and teaches nothing.

# overgeneric.py
def title[T](ticket_id: T) -> str:
    return f"ticket {ticket_id}"


def main() -> None:
    print(title(7))
    print(title("seven"))


if __name__ == "__main__":
    main()

Run:

uv run python overgeneric.py

Output:

ticket 7
ticket seven

T is unused as a constraint. You built a function from object to str and gave it a type parameter as decoration. Write ticket_id: int.

The other trap: mixing TypeVar and [T] in one module for the same idea. Pick PEP 695. Reach for TypeVar when you need TypeVar("T", bound=SomeClass) or TypeVar("T", int, str) and your checker still wants that form — not as a default.

Do not invent DeskContainer[T] for a list. list[T] already exists.

The boring rule

  • New generics: def first[T](...) and class DeskStack[T]:.
  • TypeVar only when a bound or an old file requires it.
  • Genericize a helper that is used with more than one element type, or a collection you will reuse.
  • A function that only takes ticket ids is int (or type TicketId = int), not [T].
  • Empty input → T | None (or raise). Do not return a magic default of the wrong type.
  • Do not wrap list in a custom generic class unless you have behavior list lacks (push/pop is borderline; a function on a list is simpler).

Try this

  1. Add last[T](items: list[T]) -> T | None next to first and print last([12, 4, 8]).
  2. In desk_stack.py, make a DeskStack[str] of shift names. Push "open" and "mid", then pop twice.
  3. Change title to take type TicketId = int. Keep it non-generic.
  4. Try TypeVar on first in a copy of first_item.py. Confirm the runtime output is unchanged. Then delete the copy and keep [T].