PYTH-102 · Lesson 11 of 12 · 26 min

Type hints

▶ Video coming soon A video version of this lesson is on the way.

Python does not require you to declare what type of value a variable or parameter holds. That flexibility is part of why Python is quick to write, and it is what makes duck typing possible. It also has a cost that grows with a program. When Jordan opens a function written three months ago, def apply(order, rate, card), the definition does not say whether order is a list or an Order object, whether rate is 0.05 or 5, or whether card may be None. The only ways to find out are to read every line of the function or every place it is called. And a mistake such as passing the price as the text "18.50" is discovered only when that line runs, possibly in front of a customer.

Type hints, also called type annotations, add optional notes to code that say what types are expected. Python itself ignores them when running the program, but people read them, editors use them to offer completions and flag mistakes as you type, and type checkers such as mypy and Pyright analyze a whole program for type errors before it ever runs. This lesson covers the syntax for annotating variables, parameters and return values, the types for collections, optional values and functions, type aliases and generic functions, protocols for duck typing, and how to run a type checker.

Annotating functions and variables

A parameter is annotated with a colon and a type after its name, and a function's return type with an arrow, ->, before the colon at the end of the def line. Variables can be annotated the same way as parameters. The annotations sit right next to the names they describe, so a reader sees the types without searching.

Annotated parameters, return type and variables
def bags_from_batch(roasted_kg: float, bag_g: int = 340) -> int:
    return int(roasted_kg * 1000 // bag_g)

reorder_point: int = 10
name: str = "Sunrise Blend"

print(bags_from_batch(10.2))
print(bags_from_batch.__annotations__)
Output
30
{'roasted_kg': <class 'float'>, 'bag_g': <class 'int'>, 'return': <class 'int'>}

Read the signature aloud: bags_from_batch takes a float called roasted_kg and an int called bag_g that defaults to 340, and returns an int. Python stores the annotations on the function in __annotations__, where tools can find them, but it does not check them. Calling bags_from_batch("10.2") still runs, and fails only inside the body. That is a deliberate design choice: type hints are documentation that tools can verify, not rules Python enforces while running.

The signature def find_price(prices: dict[str, float], sku: str) -> float | None: with labeled arrows. prices is the parameter name; dict[str, float] is its type, a dictionary with string keys and float values; sku: str is another annotated parameter; and float | None after the arrow is the return type, a price or None if it is missing. A note below says Python ignores annotations when running the code, while editors and type checkers such as mypy use them to find mistakes before it runs.
The parts of an annotated signature
Annotations are not checked when the program runs
def add_tax(amount: float, rate: float) -> float:
    return round(amount * (1 + rate), 2)

print(add_tax(10, 0.05))          # an int where a float is expected: fine, ints are accepted as floats
try:
    print(add_tax("10", 0.05))    # wrong type: Python runs it anyway, and it fails inside
except TypeError as err:
    print("TypeError at run time:", err)
Output
10.5
TypeError at run time: can't multiply sequence by non-int of type 'float'

A type checker would have reported the second call as an error without running anything, pointing at the exact line and saying that a str is not compatible with float. Catching mistakes at that stage, while you are editing, is the main practical benefit of type hints. Note that type checkers accept an int wherever a float is expected, because every whole number is also a valid real number.

Collections

For collections, the type of the container is followed by the types of its contents in square brackets. list[str] is a list of strings, dict[str, int] is a dictionary with string keys and int values, set[str] is a set of strings, and tuple[str, float] is a tuple of exactly two items, a string and a float. tuple[int, ...] with an ellipsis means a tuple of any length whose items are all ints.

Annotating lists, dictionaries, tuples and sets
def low_stock(stock: dict[str, int], limit: int = 10) -> list[str]:
    return sorted(code for code, bags in stock.items() if bags < limit)

def price_range(prices: list[float]) -> tuple[float, float]:
    return min(prices), max(prices)

def customers(orders: list[tuple[str, str]]) -> set[str]:
    return {name for name, item in orders}

print(low_stock({"RC-104": 9, "RC-101": 42, "RC-105": 12}, 15))
print(price_range([18.50, 22.00, 19.75]))
print(sorted(customers([("Priya", "latte"), ("Sam", "tea"), ("Priya", "mocha")])))
Output
['RC-104', 'RC-105']
(18.5, 22.0)
['Priya', 'Sam']

Writing list[str] directly has worked since Python 3.9. Older code imports capitalized versions from the typing module, such as List[str] and Dict[str, int]; they mean the same thing and you will still see them in existing projects, but new code should use the built-in names.

Optional values and unions

Many functions return a value or None: a lookup that finds nothing, a parse that fails. The annotation str | None says a value is either a string or None, and the vertical bar can join any types, as in int | float. Older code writes Optional[str] and Union[int, float] from the typing module for the same ideas. Marking None explicitly is one of the most useful things type hints do, because forgetting that a function can return None is a very common bug, and type checkers catch it reliably.

A function that may return None, and a caller that handles it
COFFEES = {"RC-101": "Sunrise Blend", "RC-102": "Ethiopia Guji"}

def find_name(sku: str) -> str | None:
    return COFFEES.get(sku.strip().upper())

def label(sku: str) -> str:
    name = find_name(sku)
    if name is None:              # a type checker insists on handling None here
        return f"{sku}: unknown"
    return f"{sku}: {name.upper()}"

print(label("rc-102"))
print(label("RC-999"))
Output
rc-102: ETHIOPIA GUJI
RC-999: unknown

If label had called name.upper() without the check, a type checker would report that name might be None, which has no upper method. Without type hints, the same bug would surface only on the day someone typed an unknown SKU. The check that narrows str | None down to str, here if name is None: return, is called type narrowing, and checkers understand isinstance tests and comparisons with None.

Try it yourself

Add type hints

Annotate these functions so that their parameters and return values are fully typed. total_bags takes a dictionary of {sku: bags} and returns an int. find_price takes a dictionary of {sku: price} and a SKU, and returns a float or None. split_sku takes a SKU like "RC-101" and returns a tuple of a str and an int, such as ("RC", 101).

Also make the functions work: the checker tests the annotations and the results.

def total_bags(stock):
    return sum(stock)

def find_price(prices, sku):
    return prices[sku]

def split_sku(sku):
    return sku.split('-')

print(total_bags({'RC-101': 42, 'RC-104': 9}))

Callables, Any and type aliases

A parameter that takes a function is annotated with Callable from collections.abc: Callable[[float], float] is a function that takes one float and returns a float. Any, from typing, means any type at all and switches checking off for that value; it is an escape hatch for code that genuinely accepts anything, best used sparingly. A long type that appears in many places can be given a name, a type alias, which keeps signatures short and gives the idea a meaningful name.

Callable for function parameters and type aliases for long types
from collections.abc import Callable

type Stock = dict[str, int]                 # a type alias (Python 3.12 syntax)
Pricing = Callable[[float], float]          # an older but still common alias style

def apply_all(prices: list[float], rule: Pricing) -> list[float]:
    return [rule(p) for p in prices]

def restock_all(stock: Stock, qty: int) -> Stock:
    return {sku: bags + qty for sku, bags in stock.items()}

def ten_off(price: float) -> float:
    return round(price * 0.9, 2)

print(apply_all([18.50, 22.00], ten_off))
print(apply_all([18.50, 22.00], lambda p: p + 1))
print(restock_all({"RC-104": 9}, 30))
Output
[16.65, 19.8]
[19.5, 23.0]
{'RC-104': 39}

The type statement was added in Python 3.12. On older versions, or in code that must support them, an ordinary assignment such as Stock = dict[str, int] does the same job and is still widely used, which is why both styles appear above.

Generic functions

Some functions work with items of any type but keep the type consistent: a function returning the first item of a list returns a str for a list of strings and an int for a list of ints. A type variable expresses that link. In Python 3.12 you declare it in square brackets after the function name; in older code it is created with TypeVar from the typing module.

Generic functions keep the item type consistent (Python 3.12 syntax)
def first[T](items: list[T], default: T) -> T:
    return items[0] if items else default

def newest[T](items: dict[str, T]) -> T | None:
    if not items:
        return None
    return items[max(items)]

print(first(["B-0412", "B-0413"], "none"))
print(first([], 0))
print(newest({"2026-10-14": "B-0412", "2026-11-07": "B-0436"}))
Output
B-0412
0
B-0436

Generics matter most in libraries and shared utility code. In everyday application code you will mostly write concrete types such as list[str], and use generics when a checker complains that a helper loses track of what type it returns.

Annotating classes and protocols

Inside classes, methods are annotated like functions, with self usually left unannotated. Dataclasses, which you met in lesson 4, are built on annotations: every annotated class attribute becomes a field. A method that returns an instance of its own class can say so with typing.Self.

A fully annotated dataclass
from dataclasses import dataclass
from typing import Self

@dataclass
class Coffee:
    sku: str
    name: str
    price: float
    stock: int = 0

    def restocked(self, qty: int) -> Self:
        return Coffee(self.sku, self.name, self.price, self.stock + qty)

    def value(self) -> float:
        return round(self.price * self.stock, 2)

c = Coffee("RC-104", "Sumatra Dark", 20.25, 9).restocked(30)
print(c, c.value())
Output
Coffee(sku='RC-104', name='Sumatra Dark', price=20.25, stock=39) 789.75

Duck typing, from lesson 5, can be typed too. A Protocol describes the methods an object must have, without requiring it to inherit from anything. Any class with matching methods satisfies the protocol automatically, so the type checker can verify duck-typed code while the classes stay independent.

A Protocol types duck-typed code without inheritance
from typing import Protocol

class Stocked(Protocol):
    name: str
    def is_low(self) -> bool: ...

class Coffee:
    def __init__(self, name: str, stock: int) -> None:
        self.name, self.stock = name, stock
    def is_low(self) -> bool:
        return self.stock < 10

class RoastBatch:
    def __init__(self, name: str, bags: int) -> None:
        self.name, self.bags = name, bags
    def is_low(self) -> bool:
        return self.bags < 20

def low_report(things: list[Stocked]) -> list[str]:
    return [t.name for t in things if t.is_low()]

print(low_report([Coffee("Sumatra Dark", 9), RoastBatch("B-0413", 12), Coffee("Sunrise Blend", 42)]))
Output
['Sumatra Dark', 'B-0413']

Running a type checker

Type checkers are separate tools installed with pip. mypy is the original Python type checker; Pyright, from Microsoft, powers the type checking in Visual Studio Code. You run them on a file or a whole project, and they report every line where the types do not fit. Most teams also run a checker automatically on every change, alongside the tests, a practice covered in PYTH-230 and PYTH-260.

Installing and running mypy on a package (terminal, with typical output)
> python -m pip install mypy
> mypy ridgeline/
ridgeline/stock.py:14: error: Argument 1 to "add_tax" has incompatible type "str"; expected "float"  [arg-type]
ridgeline/cli.py:42: error: Item "None" of "str | None" has no attribute "upper"  [union-attr]
Found 2 errors in 2 files (checked 6 source files)

Type hints are optional and can be added gradually: a checker only checks the code that has annotations, so a project can start with its most important modules and add more over time. Fully annotating every line of a short script is rarely worth the effort. Annotating the public functions of a module, the ones other code calls, gives most of the benefit for little work, and it is a good default for everything from here on in this program.

Worked problem

Annotating the cafe bill

Annotate Mei's bill function. It takes a list of (item, price) pairs, an optional loyalty card number that may be missing, and a tax rate with a default of 0.05, and it returns a dictionary with the keys subtotal, tax and total.

Show solution
  1. Items: list[tuple[str, float]].
  2. Card: str | None = None, so the caller can leave it out.
  3. Rate: float = 0.05.
  4. Return: dict[str, float], since every value is an amount.
  5. For a fixed set of keys with different types, a dataclass or TypedDict would be more precise; for three amounts, dict[str, float] is clear enough.

Answer: def bill(items: list[tuple[str, float]], card: str | None = None, rate: float = 0.05) -> dict[str, float]

Try it yourself

A typed protocol

Write a Protocol called Priced that requires a price attribute of type float. Then write cheapest(items: list[Priced]) -> Priced | None that returns the item with the lowest price, or None for an empty list.

The setup defines two unrelated classes, Coffee and Mug, that both have a price attribute; your function should work with a mixed list of them.

from typing import Protocol

def cheapest(items):
    return items[0]

print(cheapest([Coffee('Sunrise Blend', 18.5), Mug('Ridgeline mug', 16.0)]).name)

Common mistakes

  • MistakeExpecting Python to reject wrong types at run time because of annotations.

    InsteadRun a type checker such as mypy or Pyright; Python ignores hints when running.

  • MistakeAnnotating a value that can be None as plain str.

    InsteadWrite str | None, and handle None before using the value.

  • MistakeWriting list(str) or dict(str, int) instead of list[str] and dict[str, int].

    InsteadUse square brackets for the contents of a collection type.

  • MistakeUsing Any everywhere to silence the checker.

    InsteadUse precise types; keep Any for values that truly can be anything.

  • MistakeRepeating a long type such as dict[str, list[tuple[str, int]]] in many signatures.

    InsteadGive it a name with a type alias.

  • MistakeForcing inheritance just so a checker accepts duck-typed objects.

    InsteadDescribe the needed methods with a Protocol.

Summary and key terms

  • Type hints annotate parameters with name: type, return values with -> type and variables with name: type = value.
  • Python stores annotations but does not enforce them; type checkers such as mypy and Pyright verify them before running.
  • Collections take their contents in brackets: list[str], dict[str, int], set[str], tuple[str, float].
  • X | None marks a value that may be None; type checkers require it to be handled.
  • Callable types function parameters, Any switches checking off, and type aliases name long types.
  • Generic functions use type variables to keep item types consistent.
  • Dataclasses are built on annotations, and a Protocol types duck-typed code without inheritance.
type hint
An annotation stating the expected type of a value.
annotation
The syntax that attaches a type hint to a name or return value.
type checker
A tool that analyses code for type errors without running it.
union type
A type written A | B, meaning a value of either type.
optional value
A value annotated T | None, which may be missing.
type narrowing
A check, such as is None or isinstance, after which a checker knows a more specific type.
Callable
The type of functions and other callable objects.
type alias
A name given to a type expression.
generic
A function or class parameterized by a type variable.
Protocol
A type defined by the methods and attributes an object must have.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. Annotate def area(w, h): return w * h for floats.

    Show answer

    def area(w: float, h: float) -> float:

  2. What is the type of {"RC-101": [2, 3]}?

    Show answer

    dict[str, list[int]]

  3. What does a type checker report for len(find_name("x")) if find_name returns str | None?

    Show answer

    An error: the argument might be None, which has no length.

  4. Write the type of a function that takes a str and an int and returns a bool.

    Show answer

    Callable[[str, int], bool]

  5. Does def f(x: int) -> int: return x * 2 raise an error for f("ab") when run?

    Show answer

    No; it returns "abab". Only a type checker would flag it.

  6. When is Any appropriate?

    Show answer

    For values that genuinely may be of any type, such as data from an unknown source before validation.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz