Generics and Type Parameters
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])→Tisint→ returnint | Nonefirst(["open", "mid"])→Tisstr→ returnstr | 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.pyOutput:
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.pyOutput:
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.pyOutput:
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.pyOutput:
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](...)andclass DeskStack[T]:. TypeVaronly 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(ortype TicketId = int), not[T]. - Empty input →
T | None(or raise). Do not return a magic default of the wrong type. - Do not wrap
listin a custom generic class unless you have behaviorlistlacks (push/popis borderline; a function on a list is simpler).
Try this
- Add
last[T](items: list[T]) -> T | Nonenext tofirstand printlast([12, 4, 8]). - In
desk_stack.py, make aDeskStack[str]of shift names. Push"open"and"mid", then pop twice. - Change
titleto taketype TicketId = int. Keep it non-generic. - Try
TypeVaronfirstin a copy offirst_item.py. Confirm the runtime output is unchanged. Then delete the copy and keep[T].