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 analyse 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.
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__)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.
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)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.
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")])))['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.
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"))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.
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))[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.
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"}))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.
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())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.
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)]))['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.
> 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
- Items: list[tuple[str, float]].
- Card: str | None = None, so the caller can leave it out.
- Rate: float = 0.05.
- Return: dict[str, float], since every value is an amount.
- 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.
Annotate def area(w, h): return w * h for floats.
Show answer
def area(w: float, h: float) -> float:
What is the type of {"RC-101": [2, 3]}?
Show answer
dict[str, list[int]]
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.
Write the type of a function that takes a str and an int and returns a bool.
Show answer
Callable[[str, int], bool]
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.
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.
Create a free account to save your progress, take quizzes and earn a certificate.