Type Annotations
Type annotations (or type hints) label the types a function expects and returns. They don't change how your code runs — Python ignores them at runtime — but they make code far easier to read, and they power editor autocomplete and tools like mypy that catch type bugs before you run. Modern Python code is full of them. This lesson builds one annotated receipt-line formatter.
Every box is live — edit and ▶ Run.
Part 1 — Annotating a variable
Add : type after a name to say what it should hold:
The : str, : int, … are the annotations. The code runs exactly as it would
without them — they're notes about intent.
Part 2 — Annotating a function
This is where annotations earn their keep. Put : type on each parameter, and
-> type before the colon for the return type:
Read the signature as: "greet takes a str called name and returns a str."
You know how to call it correctly without reading the body.
Part 3 — Annotating collections
You can be specific about what's inside a list, dict, or tuple:
list[float] = "a list of floats". dict[str, float] = "a dict with string keys
and float values". Clear at a glance.
Part 4 — "Maybe nothing": | None
Sometimes a value might be missing. str | None means "a str or None":
Part 5 — They're hints, not handcuffs
Crucially, annotations are not enforced at runtime. Python won't stop you from passing the "wrong" type — the annotation is just documentation:
def double(n: int) -> int:
return n * 2
print(double("ab"))That's why the task below is checked by its behavior: write the annotations correctly for readability, and make the function return the right string.
Part 6 — Idioms & real-world patterns
The | union you just saw is the modern style. Here's the rest of the toolkit you
meet in real, typed codebases.
Optional, Union, and tuples — the typing names
X | None is the same as Optional[X]; A | B is the same as Union[A, B]. The
| form is newer (3.10+); the typing names are everywhere in existing code:
Annotate a fixed-shape tuple with the type of each slot:
TypeAlias, Callable, Any
Give a complex type a name so signatures stay readable, and type the functions
you pass around with Callable[[args], return]:
Coming from Java/TS: these map cleanly —
X | None≈ TSX | null,Optional[X]≈ TS optional,TypeAlias≈ TStype Menu = …,Callable[[float], float]≈ TS(x: number) => number. The key difference: Python does not enforce them at runtime — that's mypy's job.
❓ Cross-question — "So
Optional[str]is an optional field, like TSstr??" The #1 TS-dev trap.Optional[X]meansX | None(the value can be None) — it does not make a key or argument optional. "Optional key" is a different thing:NotRequiredin aTypedDict, or a parameter default (def f(x=None)). A field can be required andOptional(must be present, but may be None).
⚡ Advanced — @dataclass: a typed record with no boilerplate
A dataclass turns a few typed fields into a full class — __init__, repr,
and == are generated for you. This is how you model domain objects:
Coming from Java: it's a
record(or Lombok@Data) — fields plus a generated constructor/equals/toString. Addfrozen=Truefor an immutable value.
⚡ Advanced — Literal, TypedDict, Protocol
For expert-level, self-documenting APIs:
Coming from Java/C#:
Protocolis a structural interface — a class satisfies it just by having the right methods (duck typing), noimplements.Literalis a lightweight enum;TypedDicttypes a JSON-ish dict.
Part 7 — Deep typing: dicts, tuples & sets (for TS devs)
Coming from TypeScript, you'll want to type shapes, not just dict/tuple/set.
Python's tools map almost 1:1 to TS — here's the deep end.
Dicts, two ways: TypedDict (fixed shape) vs dict[K, V] (uniform)
A TypedDict types a dict whose keys are known — like a TS interface. It
nests, and NotRequired marks optional keys:
❓ Cross-question — "How do I make ALL keys optional, like TS
Partial<T>?" Settotal=Falseon the class:class Draft(UserProfile, total=False)makes every key optional. The inverse — one must-have key inside atotal=Falsedict — isRequired[...]. Per-field,NotRequired/Required≈ TS?:.
When keys are dynamic (not known ahead of time), type the value with a union —
this is TS's Record<K, V>:
❓ Cross-question — "
TypedDictvsdataclass— which one?" Use aTypedDictwhen the data really is a plain dict (e.g. JSON from an API) and you want dict access. Use a@dataclasswhen you want a real object with methods, defaults, andobj.fieldaccess. TS analogy:interfacefor a shape you pass around vsclassfor behaviour.
Tuples: fixed shape vs variable length
tuple[int, str, bool] is a fixed 3-slot tuple (each slot typed) — TS's
[number, string, boolean]. tuple[int, ...] (with the literal ...) means "a tuple
of any length, all int" — TS's number[]:
❓ Cross-question — "Is
tuple[int, str]a readonly tuple like in TS?" Yes — tuples are immutable, sotuple[int, str]≈ TSreadonly [number, string]. For named slots (the TS "object-ish tuple"), use aNamedTuple.
Sets: set[T] and frozenset[T]
Remember: a
set's element type (and a dict's key type) must be hashable/immutable — there's noset[list[int]]. (See Lesson 11.)
Name gnarly types with TypeAlias
❓ Cross-question — "Do any of these run-check the shape?" No — like TypeScript, Python hints are erased at runtime; passing the wrong shape won't raise. A checker (mypy or pyright — pyright is the engine behind your TS editor tooling) flags it before you run. Hints document and tool-check; they don't enforce.
🎯 Your turn
Write receipt_line(name, price) with type annotations — it returns one line
of the receipt as "name: $price" with the price to two decimals:
receipt_line("Soup", 10)→"Soup: $10.00"receipt_line("Steak", 20.5)→"Steak: $20.50"
Hint — annotate the signature def receipt_line(name: str, price: float) -> str:
then return f"{name}: ${price:.2f}".
Then press ▶ Run, tap the Live App try chips, and hit ✓ Check. Green = this piece of the app is built. ✅
