Chapter 20 of 24 · Python for Beginners — Complete Free Tutorial
Type Hints in Python
In the Python for Beginners series your functions have always said what they do by doing it; Chapter 20 of this 24-chapter course teaches them to say it out loud. A signature like def add(a: int) -> int states the contract in the code itself, and this chapter covers the annotation syntax, the essential typing names, why the interpreter ignores all of it at runtime, and how a checker like mypy catches mistakes before your users do.
Annotating functions, variables and returns
Annotations are expressions: after a colon for parameters and variables, after -> for the return value. They are optional everywhere and enforced nowhere, but they turn your code into documentation that editors, IDEs and checkers can act on:
def add(a: int, b: int) -> int:
return a + b
total: int = add(2, 3)
names: list[str] = ["Ada", "Grace"]
print(total, names[0])
Expected output: 5 Ada
The same idea applies to module-level constants, dataclass fields (see chapter 17) and attributes assigned later in a class body. Annotate the boundaries — public functions and the data structures that cross them — rather than every local variable.
None, unions and built-in generics
A function that may return nothing is annotated str | None, the modern spelling. The older Optional[str] from typing means exactly the same thing and still appears in older code and library stubs. Since Python 3.9 the built-ins are generic too: list[int], dict[str, int], set[str] and tuple[int, ...] (a tuple of any length whose items are integers):
from typing import Optional
def find_user(user_id: int) -> str | None:
users = {1: "Ada"}
return users.get(user_id)
def find_user_legacy(user_id: int) -> Optional[str]:
return find_user(user_id)
def last(items: tuple[int, ...]) -> int:
return items[-1]
scores: dict[str, list[float]] = {"ada": [90.0]}
print(find_user(1), find_user(99))
print(find_user_legacy(99), last((4, 5, 6)))
print(scores["ada"][0])
Expected output:
Ada None
None 6
90.0
The typing toolbox
Eight names cover almost every day-to-day need:
| Name | What it says | Example |
|---|---|---|
Any | anything goes; checking stops here | payload: Any |
Union[X, Y] | the old spelling of X | Y | Union[int, str] |
TypeVar | a placeholder type reused across a signature | T = TypeVar("T") |
Generic[T] | makes your own class or function parameterised | class Box(Generic[T]) |
Protocol | structural interface: “anything with these methods” | class Serializable(Protocol) |
TypedDict | a plain dict with known keys and value types | class UserDict(TypedDict) |
Literal | only these exact values are accepted | Mode = Literal("read") |
Annotated | extra metadata such as units, attached to a type | Metres = Annotated[int, "m"] |
Four of them in one file — note that Literal fits a signature as mode: Mode = "read", and Any deliberately switches checking off:
from typing import Any, Literal, TypedDict, TypeVar
Mode = Literal["read", "write"]
T = TypeVar("T")
class UserDict(TypedDict):
id: int
name: str
def first(items: list[T]) -> T | None:
return items[0] if items else None
user: UserDict = {"id": 1, "name": "Ada"}
payload: Any = user
print(first([1, 2]), user["name"], payload["name"])
Expected output: 1 Ada Ada
Hints are not checked at runtime
The interpreter stores annotations and then shrugs — nothing validates them. The call below runs happily and returns a string from an int-typed function, because hints live in __annotations__ and nowhere else:
def double(x: int) -> int:
return x * 2
print(double("ha"))
print(double.__annotations__)
Expected output:
haha
{'x': <class 'int'>, 'return': <class 'int'>}
That is not a flaw in your code — it is the design. The runtime stays flexible; enforcement belongs to a checker. Keep tests for behaviour (chapter 21) and hints for shapes.
Catching mistakes with mypy
mypy reads your hints and reports mismatches without running the program; pyright (the engine behind VS Code’s Pylance) does the same job. Install with python3 -m pip install mypy, then point it at a file. This one has a deliberate mistake on the last line:
def average(numbers: list[int]) -> float:
return sum(numbers) / len(numbers)
total = average([1, 2, 3])
print(f"average = {total}")
print(average("not a list"))
Expected output: running python3 -m mypy bad_average.py:
bad_average.py:7: error: Argument 1 to "average" has incompatible type "str"; expected "list[int]" [arg-type]
Found 1 error in 1 file (checked 1 source file)
Run the same file with python3 instead and it prints average = 2.0, then crashes with TypeError: unsupported operand type(s) for +: 'int' and 'str' deep inside average. The checker points at the bad call; the runtime points at the consequences. Fix the call and mypy answers Success: no issues found in 1 source file — and it exits with status 1 while errors remain, which is what makes it a clean CI gate. Run it across a whole project with python3 -m mypy .; pyright is invoked the same way as pyright file.py.
Common mistakes
- Annotating the obvious and skipping the edges — labelling every local adds noise, while leaving public function signatures bare gives a checker nothing to work with. Signatures first.
- Reaching for Any to silence a complaint —
Anydisables checking for everything downstream of it; preferobject, a union, or aTypeVar. - Treating hints as runtime validation — a wrong call may still run and corrupt data, as
double("ha")proved. Hints need a checker to mean anything. - Mixing spellings in one file —
Optional[X]andX | Noneare identical, but alternating between them reads like two authors; pickX | Nonein new code. - Checking only one file — mypy is quiet about unimported modules by default, so run
python3 -m mypy .over the project or errors hide in the files you forgot.
Practice checklist
- Annotate three functions from an old script and run mypy; fix everything it reports.
- Rewrite every
Optional[X]you wrote asX | Noneand confirm mypy stays silent. - Define a
TypedDictfor a JSON payload you normally treat as an untyped dict. - Prove the runtime does not enforce hints: call a function with the wrong type and watch it run.
- Run
python3 -m mypy .across a small project and note which errors surprise you most.
What's next?
Your intentions are now written down — but nothing yet proves the code behaves. Next you build the safety net: Testing with pytest shows how tests are named, grouped and run so refactors stop being frightening.
Frequently Asked Questions
Python is a general-purpose language first released in 1991, designed around one idea: code is read far more often than it is written. English keywords and indentation instead of braces make it readable, the standard library covers files, JSON, dates and testing, and pip adds thousands more packages. The same language handles scripts, web APIs, data work and machine learning.
None at all. Python is widely considered the friendliest first language: no declaration keywords, no braces, no build step, and error messages that name the problem. This series begins at your first print() call. If you already read JavaScript or TypeScript you will adjust quickly, but no previous language is assumed anywhere in these 24 chapters.
Python 3 only, any release from 3.12 onwards. Python 2 reached end of life in 2021 and its print "hi" statements and xrange() no longer run. One historical quirk remains: installers ship the interpreter as python3 on macOS and Linux, while Windows usually offers python or the py launcher, and every command in this series works with that one-word swap.
More than you might expect. Everyday automation such as renaming files, cleaning spreadsheets or scraping pages; web backends with Django, Flask or FastAPI; data analysis and machine learning with pandas, NumPy and PyTorch; DevOps tooling, desktop apps and games. Python is also the classic glue language, calling C, Rust or Fortran libraries from short readable scripts.
Pure Python can run 10 to 100 times slower than C, yet that rarely blocks you: heavy numeric work lives in C extensions such as NumPy, which Python coordinates. Dynamic typing does push errors to runtime, surfacing as a TypeError on the line that finally uses the bad value. Type hints checked by mypy move those mistakes back into your editor.
Only the interpreter. Install CPython from python.org, version 3.12 or newer, confirm it with python3 --version, and open a plain text editor; VS Code with the Python extension is the recommendation here. The early chapters use no third-party packages, so there is nothing else to set up until virtual environments and pip arrive.
A dict is a real runtime mapping: keys may be any hashable value rather than only strings, insertion order has been guaranteed since Python 3.7, and a missing key raises KeyError unless you ask with get(). JavaScript objects coerce every key to a string and carry a prototype chain. A TypeScript interface is erased at compile time and describes shape only; TypedDict is Python's checked equivalent.
Reach for a list when items will be added, removed or reordered: it is ordered, mutable and rich with methods. Pick a tuple for fixed records and function returns, because it is immutable and hashable, so it can be a dictionary key. Choose a set for unique values and fast membership checks; it has no order and de-duplicates on creation.
Yes, gradually. Annotate public function signatures first; the interpreter ignores hints at runtime, so a checker such as mypy is what gives them teeth. Add tests the same way: pytest collects any test_*.py file and plain assert statements, and a suite that finishes in a tenth of a second is one you will run after every change. Both habits cost little now and much more later.
JavaScript and TypeScript keep the browser, so anything rendered to the user stays theirs. Python takes the server: Django for full-featured applications, Flask for small ones, FastAPI for typed async APIs where your annotations become automatic documentation. Your TypeScript habits transfer directly, and asyncio mirrors the event loop you already know, just with different keywords.