Type Hints in Practice

Updated

September 8, 2026

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.py

Output:

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.py

Output:

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.py

Output:

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.py

Output:

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.py

Output:

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.py

Output:

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, not typing.List.
  • Absent values: T | None. Not Optional[T] in new code.
  • Name repeated ints with type Cents = int (or TypeAlias if you are matching older files).
  • Do not import List, Dict, Optional for new hints.
  • Do not use Any except 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

  1. Change shift_hours.py so it also prints total_hours([]). Keep the return type int.
  2. Add a function tables(counts: dict[str, int]) -> list[str] that returns list(counts) and print it.
  3. Rewrite optional_mix.py to use int | None and delete the typing import.
  4. Add type TicketId = int to maybe_table.py and use it on ticket_id and the dict keys.