The total came out as “33”, three files away from the bug
See the problem
Section titled “See the problem”A customer orders 3 mugs at £2 each. The order page shows a total of 33.
The bug took a day to find. The web form gives every value as text, so qty was "3", not 3. And in Python, 2 * "3" is not an error: it repeats the string, giving "33". Nothing failed until the total reached the payment code, three files away.
Define the task
Section titled “Define the task”- Input: a function,
order_total(price, qty), and the code that calls it. - Output: a warning about the wrong type, as early as possible: ideally before the code runs at all.
- Rules: no extra work on every call in production.
- The hard part: Python doesn’t check types until an operation fails, and
2 * "3"doesn’t fail.
Pause and think
Section titled “Pause and think”Hints open one at a time:
Hint 1 of 3
A docstring says it, but no tool reads docstrings.
Hint 2 of 3
isinstance checks catch it, but only when that line runs, and they run on every call.
Hint 3 of 3
Write qty: int in the def line. A checker such as mypy reads that and checks every caller.
Understand the idea
Section titled “Understand the idea”Type hints label a function’s arguments and result:
def order_total(price: float, qty: int) -> float: ...def find_user(users: dict[int, str], user_id: int) -> str | None: ...Python stores them and ignores them. They go in order_total.__annotations__, and order_total(2, "3") still runs. The benefit comes from a type checker, such as mypy or pyright, which reads the code without running it:
$ mypy shop.pyshop.py:7: error: Argument 2 to "order_total" has incompatible type "str"; expected "int"Editors run the same checks as you type, and underline the mistake in red.
| Hint | Means |
|---|---|
int, str, float | That class. A checker lets an int pass as a float. |
list[int], dict[str, int] | A list of ints, a dict from strings to ints. |
str | None | A string, or None. Older code writes Optional[str]. |
Callable[[int], str] | A function that takes an int and returns a string. |
T = TypeVar("T") | “Some type”, the same one everywhere T appears. |
Any | Anything; the checker stops checking. |
Climb the ladder
Section titled “Climb the ladder”All three run order_total in O(1). What changes is when the wrong type is found.
Say it in a docstring
The idea: Write the expected types in the docstring, and hope callers read it.
People reading the function learn the types.
def order_total(price, qty): """price is a float, qty is an int.""" # nothing checks this return price * qty
form = {"qty": "3"} # values from a web form are always texttotal = order_total(2, form["qty"]) # "33": a string, three files later- Time
O(1)- Space
O(1)- Why
- No checks at all.
Where it breaks: no tool reads the docstring. order_total(2, "3") returns "33", and the bug shows up far away.
isinstance on every call
The idea: Raise TypeError when an argument has the wrong type.
The waste in Okay: the wrong value travels on silently.
Now the error appears at the call, with a clear message.
Highlighted lines are new in this rung.
def order_total(price, qty): if not isinstance(price, (int, float)): raise TypeError(f"price must be a number, not {type(price).__name__}") if not isinstance(qty, int): raise TypeError(f"qty must be an int, not {type(qty).__name__}") return price * qty # the checks run on every call, and only when it runs- Time
O(1) extra per call- Space
O(1)- Why
- Two checks on every call, forever.
What changed from the previous rung
def order_total(price, qty): """price is a float, qty is an int.""" # nothing checks this return price * qty
form = {"qty": "3"} # values from a web form are always texttotal = order_total(2, form["qty"]) # "33": a string, three files later if not isinstance(price, (int, float)): raise TypeError(f"price must be a number, not {type(price).__name__}") if not isinstance(qty, int): raise TypeError(f"qty must be an int, not {type(qty).__name__}") return price * qty # the checks run on every call, and only when it runsWhere it breaks: it only fails when that call runs, maybe first in production. And every function needs these lines, which run on every call.
Type hints and a checker
The idea: Write price: float, qty: int -> float, and let mypy check every caller.
The waste in Better: a run-time check for a mistake you could find before running.
mypy reports order_total(2, form["qty"]) because form is dict[str, str]. The fix, int(form["qty"]), goes where the text comes in.
Highlighted lines are new in this rung.
def order_total(price: float, qty: int) -> float: return price * qty
form: dict[str, str] = {"qty": "3"}# mypy, before the code runs:# error: Argument 2 to "order_total" has incompatible type "str"; expected "int"total = order_total(2, int(form["qty"])) # the fix mypy pushed us to- Time
O(1)- Space
O(1)- Why
- The check happens before the code runs, so calls cost nothing extra.
What changed from the previous rung
def order_total(price, qty): if not isinstance(price, (int, float)): raise TypeError(f"price must be a number, not {type(price).__name__}") if not isinstance(qty, int): raise TypeError(f"qty must be an int, not {type(qty).__name__}") return price * qty # the checks run on every call, and only when it runsdef order_total(price: float, qty: int) -> float: return price * qty
form: dict[str, str] = {"qty": "3"}# mypy, before the code runs:# error: Argument 2 to "order_total" has incompatible type "str"; expected "int"total = order_total(2, int(form["qty"])) # the fix mypy pushed us toWhen Best isn’t best: hints don’t check data from outside, like JSON or a form, at run time. Check that at the edge, with code or a library such as Pydantic.
Write it yourself
Section titled “Write it yourself”Hints are just data, so you can read them. Write wrong_args(func, *args, **kwargs), which returns the names of the arguments whose values don’t match their hints. Skip arguments without a hint.
Hint 1 of 3
inspect.signature(func).bind(*args, **kwargs).arguments maps each name to its value, keyword or not.
Hint 2 of 3
typing.get_type_hints(func) maps names to types, and turns hints written as text, like "int", into real types.
Hint 3 of 3
isinstance(value, int | None) works on X | None hints.
Which arguments break their hints?
Ctrl + Enter runs- Not run yet:
wrong_args(price, 9.5, "3")→['qty'] - Not run yet:
wrong_args(price, "x", qty="y")→['amount', 'qty'] - Not run yet:
a has no hint: wrong_args(loose, "x", "y")→['b'] - Not run yet:
a hint written as text: n: "int"→['n'] - Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Show an answer
import inspectimport typingfrom collections.abc import Callable
def wrong_args(func: Callable, *args: object, **kwargs: object) -> list[str]: hints = typing.get_type_hints(func) # turns "int" strings into real types bound = inspect.signature(func).bind(*args, **kwargs) # match values to names return [ name for name, value in bound.arguments.items() if name in hints and not isinstance(value, hints[name]) ]bind matches values to names the way a real call does. get_type_hints resolves text hints, which is what you get in files that start with from __future__ import annotations.
Compare
Section titled “Compare”| Way | Finds order_total(2, "3") | Cost per call | Use it when |
|---|---|---|---|
| Okay: a docstring | Never | None | Never on its own |
Better: isinstance checks | When that call runs | Two checks | Data from outside, at the edge |
| Best: hints and mypy | Before the code runs | None | All your own code |
Common mistakes and edge cases
Section titled “Common mistakes and edge cases”- A mutable default hides
None:def f(xs: list[int] = None)is wrong. Writexs: list[int] | None = None. floatacceptsintfor a checker, because ints work anywhere floats do.isinstance(3, float)is stillFalse.list[int]needs Python 3.9+, andX | Noneneeds 3.10+. Older code importsListandOptionalfromtyping.Anyturns checking off. Useobjectwhen you mean “anything, and I won’t use it as a specific type”.- Use
Sequence[T]orIterable[T]for arguments, notlist[T], so tuples and generators are accepted too.
Say it in the interview
Section titled “Say it in the interview”Questions to ask first
- Does the team run mypy or pyright in CI, and how strict?
- Where does outside data come in, so I check it at run time there?
A 60-second walkthrough
“Python only finds a wrong type when an operation fails, and
2 * '3'doesn’t fail: it gives'33'. Type hints label the signature:price: float, qty: int -> float. Python stores them in__annotations__but never checks them. The value comes from mypy or pyright, which read the code without running it. They flagorder_total(2, form['qty']), because the form gives strings. That costs nothing per call. For data from outside, like JSON, hints can’t help at run time, so I’d check it where it enters, with code or Pydantic.”
Likely follow-ups
- What is a
TypeVarfor? When the result’s type follows an argument’s:first(items: Sequence[T]) -> Treturns an int for a list of ints. - What is a
Protocol? A type defined by methods, not by inheritance: anything with a.read()method fits. The ABCs and Protocols lesson covers it. - Do hints make Python faster? No. CPython ignores them; tools like mypyc can compile hinted code, but that’s separate.
Practice
Section titled “Practice”Three problems, from easy to hard. Each has hints, tests you can run here, and its own Okay, Better and Best answers. Try before you open them.
1. A hint that tells the truth (easy)
Section titled “1. A hint that tells the truth (easy)”find_user(users, user_id) returns the user’s name, or None if there is no such user. Add hints: users is a dict from ints to strings. The last test reads your return hint.
Hint 1 of 3
Hint users as a dict from ints to strings, and user_id as an int.
Hint 2 of 3
dict.get returns None for a missing key, so the result can be None.
Hint 3 of 3
The return hint is str | None.
A hint that tells the truth
Ctrl + Enter runs- Not run yet:
find_user({1: "ana"}, 1)→'ana' - Not run yet:
find_user({1: "ana"}, 2)→None - Not run yet:
is the return hint str | None?→True
Try your own input
Show the Okay, Better and Best answers
No hints
The idea: Return users.get(user_id).
It works.
def find_user(users, user_id): # no hints yet return users.get(user_id)- Time
O(1)- Space
O(1)- Why
- One dict look-up.
Where it breaks: a caller who writes find_user(u, 2).upper() gets no warning until it crashes on None.
-> str
The idea: Hint the result as str.
The waste in Okay: callers have to guess the types.
Highlighted lines are new in this rung.
def find_user(users: dict[int, str], user_id: int) -> str: # wrong: can be None return users.get(user_id) # mypy: incompatible return value "str | None"- Time
O(1)- Space
O(1)- Why
- One dict look-up.
What changed from the previous rung
def find_user(users, user_id): # no hints yet return users.get(user_id)def find_user(users: dict[int, str], user_id: int) -> str: # wrong: can be None return users.get(user_id) # mypy: incompatible return value "str | None"Where it breaks: the hint lies. mypy flags the return line, and callers trust a str that is sometimes None.
-> str | None
The idea: The honest hint: the result can be None.
The waste in Better: a hint that hides the one case callers must handle.
Now mypy makes every caller check for None before using the name.
Highlighted lines are new in this rung.
def find_user(users: dict[int, str], user_id: int) -> str | None: return users.get(user_id) # mypy now makes callers check for None- Time
O(1)- Space
O(1)- Why
- One dict look-up.
What changed from the previous rung
def find_user(users: dict[int, str], user_id: int) -> str: # wrong: can be None return users.get(user_id) # mypy: incompatible return value "str | None"def find_user(users: dict[int, str], user_id: int) -> str | None: return users.get(user_id) # mypy now makes callers check for NoneWhen Best isn’t best: if a missing user is a bug, not a normal case, raise KeyError and keep the hint -> str.
2. First item, any type (medium)
Section titled “2. First item, any type (medium)”first(items, default) returns the first item, or default if items is empty. Hint it so a checker knows first([3, 4], 0) is an int and first("hi", "?") is a string. The last test checks for a TypeVar.
Hint 1 of 3
Any works, but then the checker knows nothing about the result.
Hint 2 of 3
A hint for lists of ints is too narrow: strings and tuples are refused.
Hint 3 of 3
T = TypeVar("T"), then items: Sequence[T], default: T, -> T.
First item, any type
Ctrl + Enter runs- Not run yet:
first([3, 4], 0)→3 - Not run yet:
first('hi', '?')→'h' - Not run yet:
first((), None)→None - Not run yet:
are the result and default the same TypeVar?→True
Try your own input
Show the Okay, Better and Best answers
Any
The idea: Hint everything as Any.
The checker stops complaining.
from typing import Any
def first(items: Any, default: Any) -> Any: return items[0] if items else default- Time
O(1)- Space
O(1)- Why
- One index.
Where it breaks: first([3], 0).upper() passes the checker too, because Any turns checking off.
Only lists of ints
The idea: Hint the one case you had in mind.
The waste in Okay: it says nothing about the result.
Highlighted lines are new in this rung.
def first(items: list[int], default: int) -> int: # only lists of ints return items[0] if items else default- Time
O(1)- Space
O(1)- Why
- One index.
What changed from the previous rung
from typing import Any
def first(items: Any, default: Any) -> Any:def first(items: list[int], default: int) -> int: # only lists of ints return items[0] if items else defaultWhere it breaks: the checker now refuses first("hi", "?") and first((1,), 0), which work fine.
A TypeVar
The idea: T stands for one type: Sequence[T] in, T out.
The waste in Better: a hint narrower than the code.
The checker works out T at each call: int for [3, 4], str for "hi".
Highlighted lines are new in this rung.
from collections.abc import Sequencefrom typing import TypeVar
T = TypeVar("T") # "some type", the same one everywhere it appears
def first(items: Sequence[T], default: T) -> T: return items[0] if items else default- Time
O(1)- Space
O(1)- Why
- One index.
What changed from the previous rung
def first(items: list[int], default: int) -> int: # only lists of intsfrom collections.abc import Sequencefrom typing import TypeVar
T = TypeVar("T") # "some type", the same one everywhere it appears
def first(items: Sequence[T], default: T) -> T: return items[0] if items else defaultWhen Best isn’t best: on Python 3.12 and later, write def first[T](items: Sequence[T], default: T) -> T: without the TypeVar line.
3. Check inside a list (hard)
Section titled “3. Check inside a list (hard)”Write matches(value, hint), which says whether a value fits a hint, looking inside: [1, "a"] does not fit list[int]. Handle plain classes, list[X], dict[K, V] and X | None.
Hint 1 of 3
isinstance() raises TypeError when the hint has brackets, like a list of ints.
Hint 2 of 3
typing.get_origin(hint) gives the outer class, like list. typing.get_args(hint) gives what is inside, like (int,).
Hint 3 of 3
For a union, get_origin is types.UnionType or typing.Union: match any of the args.
Check inside a list
Ctrl + Enter runs- Not run yet:
matches(3, int)→True - Not run yet:
matches([1, 2], list[int])→True - Not run yet:
matches([1, "a"], list[int])→False - Not run yet:
matches({"a": ["x"]}, dict[str, list[int]])→False - Not run yet: Extra test
- Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Show the Okay, Better and Best answers
isinstance
The idea: isinstance(value, hint).
Right for plain classes and for int | None.
def matches(value: object, hint: type) -> bool: return isinstance(value, hint) # TypeError on list[int]- Time
O(1)- Space
O(1)- Why
- One check.
Where it breaks: isinstance([1], list[int]) raises TypeError: isinstance() argument 2 cannot be a parameterized generic.
The outer class
The idea: Check the value against get_origin(hint): the outer class, like list.
The waste in Okay: it can’t take generic hints at all.
Highlighted lines are new in this rung.
import typing
def matches(value: object, hint: object) -> bool: outer = typing.get_origin(hint) or hint # list[int] -> list return isinstance(value, outer) # never looks inside- Time
O(1)- Space
O(1)- Why
- One check.
What changed from the previous rung
def matches(value: object, hint: type) -> bool: return isinstance(value, hint) # TypeError on list[int]import typing
def matches(value: object, hint: object) -> bool: outer = typing.get_origin(hint) or hint # list[int] -> list return isinstance(value, outer) # never looks insideWhere it breaks: it never looks inside, so ["a"] fits list[int]. And int | None has the origin UnionType, which no value is an instance of.
Look inside, one level at a time
The idea: Split the hint with get_origin and get_args, and call matches on each item.
The waste in Better: it trusts the outside of the box.
Highlighted lines are new in this rung.
import typesimport typing
def matches(value: object, hint: object) -> bool: origin = typing.get_origin(hint) if origin is None: # a plain class like int return isinstance(value, hint) args = typing.get_args(hint) if origin in (typing.Union, types.UnionType): # int | None return any(matches(value, a) for a in args) if not isinstance(value, origin): return False if origin is list: return all(matches(v, args[0]) for v in value) if origin is dict: key, val = args return all(matches(k, key) and matches(v, val) for k, v in value.items()) return True # other generics: the outer check is all we do- Time
O(n)- Space
O(d)- Why
- Each of the n items inside is checked once; the recursion is d levels deep.
What changed from the previous rung
import typesimport typing
def matches(value: object, hint: object) -> bool: outer = typing.get_origin(hint) or hint # list[int] -> list return isinstance(value, outer) # never looks inside origin = typing.get_origin(hint) if origin is None: # a plain class like int return isinstance(value, hint) args = typing.get_args(hint) if origin in (typing.Union, types.UnionType): # int | None return any(matches(value, a) for a in args) if not isinstance(value, origin): return False if origin is list: return all(matches(v, args[0]) for v in value) if origin is dict: key, val = args return all(matches(k, key) and matches(v, val) for k, v in value.items()) return True # other generics: the outer check is all we doWhen Best isn’t best: real checkers handle far more: tuples, TypedDict, Literal. Use Pydantic or typeguard rather than growing this.
Check yourself
Section titled “Check yourself”A few quick questions. Ones you miss are remembered, so you can find your weak spots later.
Flashcards
Section titled “Flashcards”Add these to your review deck. Each card comes back a little later every time you get it right.
Cheat sheet
Section titled “Cheat sheet”def f(x: int, names: list[str]) -> bool:.str | Nonefor “maybe missing”; defaultNoneneeds it.Sequence[T],Iterable[T]for arguments;TypeVarto link types.- Python ignores hints; mypy or pyright check them.
typing.get_type_hints(f)reads them at run time.
Go deeper
Section titled “Go deeper”typing, with the type hints cheat sheet linked from it.- The mypy cheat sheet.
- PEP 484, which added type hints.
- Before this: Functions. Next: is vs ==, and copies.
Can you do these now?
If you’re not sure of any of these, scroll back to that part before moving on.
Status: Not started