Type Hints in Practice
Type Hints in Practice
Python 3.14 writes collection types with the builtins: list[int], dict[str, int], int | None. You do not import List, Dict, or Optional for new code. A type alias names a meaning (Cents, TableId) so the hint reads like the desk. The boring default is those builtins plus | None. Leave Any for the rare untyped boundary.
Mental model
A hint is a type expression:
| You mean | You write |
|---|---|
| list of ints | list[int] |
| dict, string keys, int values | dict[str, int] |
| int or missing | int \| None |
| a named int | type Cents = int or Cents: TypeAlias = int |
X | Y is a union. None in a union is optional in the English sense: the value may be absent. The older spelling Optional[int] is int | None. Prefer the pipe.
Aliases do not create new runtime types. Cents is still int. They exist so price: Cents is not a comment you forget to update.
This chapter stays on builtins plus TypeAlias. No TypedDict, no Literal, no Annotated until a later need names them.
Worked examples
Case 1: list[int]
Save as shift_hours.py. The function only accepts a list of hours. A list of names is a checker error even if you never append here.
# shift_hours.py
def total_hours(hours: list[int]) -> int:
n = 0
for h in hours:
n += h
return n
def main() -> None:
print(total_hours([4, 6, 5]))
if __name__ == "__main__":
main()Run:
uv run python shift_hours.pyOutput:
15
tuple[int, ...] is a tuple of ints of unknown length. tuple[int, int] is a pair. Use a list when the length varies.
Case 2: dict[str, int]
Save as seat_counts.py. Keys are table labels as strings. Values are seat counts.
# seat_counts.py
def seats_for(table: str, counts: dict[str, int]) -> int:
return counts[table]
def main() -> None:
counts = {"12": 4, "4": 2}
print(seats_for("12", counts))
if __name__ == "__main__":
main()Run:
uv run python seat_counts.pyOutput:
4
dict[int, int] would be table numbers. Mixing seats_for(12, counts) with string keys is the bug a checker is for. Pick one key type and keep it.
Case 3: T | None
Save as maybe_table.py. .get returns None on a miss. Spell that in the return type.
# maybe_table.py
def table_for(ticket_id: int, board: dict[int, int]) -> int | None:
return board.get(ticket_id)
def main() -> None:
board = {7: 12, 8: 4}
found = table_for(7, board)
missing = table_for(9, board)
print(found)
print(missing)
if __name__ == "__main__":
main()Run:
uv run python maybe_table.pyOutput:
12
None
Callers must handle None. If the miss is a defect, raise KeyError (use board[ticket_id]) and return int instead. Do not return None and also raise. Pick one.
Case 4: TypeAlias and the type statement
Save as cents_alias.py. TypeAlias is the explicit typing-module spelling. It is still valid in 3.14.
# cents_alias.py
from typing import TypeAlias
Cents: TypeAlias = int
TableId: TypeAlias = int
def label(table: TableId, price: Cents) -> str:
return f"table {table}: {price} cents"
def main() -> None:
print(label(12, 850))
if __name__ == "__main__":
main()Run:
uv run python cents_alias.pyOutput:
table 12: 850 cents
The same idea with the 3.12+ statement. Prefer this in new 3.14 code.
# cents_type_stmt.py
type Cents = int
type TableId = int
def label(table: TableId, price: Cents) -> str:
return f"table {table}: {price} cents"
def main() -> None:
print(label(12, 850))
if __name__ == "__main__":
main()Run:
uv run python cents_type_stmt.pyOutput:
table 12: 850 cents
Two aliases of int are still interchangeable at runtime — and most checkers treat them as int unless you use a NewType. That is fine for desk code. The alias is for readers.
The trap
Mixing spellings and emptying the type.
# optional_mix.py
from typing import Optional
def table_for(ticket_id: int, board: dict[int, int]) -> Optional[int]:
return board.get(ticket_id)
def main() -> None:
print(table_for(7, {7: 12}))
if __name__ == "__main__":
main()Run:
uv run python optional_mix.pyOutput:
12
It runs. It is also the old union. In the same file, int | None and Optional[int] make the reader hunt for a difference that is not there. Pick X | None.
Any is the other leak. def load(raw: Any) -> Any turns the checker off. If JSON came in, parse it into dict[str, int] (or a dataclass) at the edge. Do not pass Any through the kitchen.
list without a parameter means “list of anything” to some checkers and “please parameterize” to others. Write list[int].
The boring rule
list[T],dict[K, V],set[T],tuple[T, ...]— builtins, nottyping.List.- Absent values:
T | None. NotOptional[T]in new code. - Name repeated ints with
type Cents = int(orTypeAliasif you are matching older files). - Do not import
List,Dict,Optionalfor new hints. - Do not use
Anyexcept at a true untyped boundary, and keep that boundary thin. - One key type per dict. String labels and integer ids are different dicts.
Try this
- Change
shift_hours.pyso it also printstotal_hours([]). Keep the return typeint. - Add a function
tables(counts: dict[str, int]) -> list[str]that returnslist(counts)and print it. - Rewrite
optional_mix.pyto useint | Noneand delete thetypingimport. - Add
type TicketId = inttomaybe_table.pyand use it onticket_idand the dict keys.