Warm-up · Activity 1 of 7
// A2.4 · ~30 min · Advanced
TypedDict, Literal and Final
After this lesson you can describe dict records with TypedDict, restrict a value to fixed options with Literal, and mark constants, methods and classes as final, and you know which of these Python checks at runtime: none.
Predict · Activity 2 of 7
Predict before you read on: year is declared as int, and the call passes a string. What does this print?
from typing import TypedDict class Movie(TypedDict): title: str year: int m = Movie(title="Alien", year="1979") print(type(m).__name__, m["year"])Practice · Activity 3 of 7
Many movies have no rating yet. Fill in the qualifier so that the rating key may be missing, while title stays required.
class Movie(TypedDict): title: str rating: ____[float]rating: [float]Practice · Activity 4 of 7
mypy reports one error for the last line. Which error code?
from typing import TypedDict class Movie(TypedDict): title: str year: int m: Movie = {"title": "Alien", "year": 1979, "rating": 8.5}Practice · Activity 5 of 7
Match each construct to what it tells the type checker.
Brain teaser · Activity 6 of 7
Brain teaser. d holds "up", which the Literal allows, yet mypy rejects move(d). What type does mypy say d has?
from typing import Literal def move(direction: Literal["up", "down"]) -> None: print("moving", direction) d = "up" move(d)Apply · Activity 7 of 7
Mini-task. Orders arrive as JSON: an id, a status that is "open", "paid" or "shipped", and sometimes a note. Describe them with a TypedDict Order, the status with a Literal alias, and the note with NotRequired. Write summary(order), which gives #1 paid, or #2 open (gift) when there is a note. Check it with mypy --strict.
Check your work against this list
Build it yourself
Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.
Worked example
Typed log events
An Event is a TypedDict whose level is a Literal alias and whose code is optional. MAX_MESSAGE is a Final constant, and Formatter is a @final class. mypy would reject a level such as "debug", a missing message, MAX_MESSAGE = 50 or a subclass of Formatter. The last lines show what is left at runtime.
main.py
from typing import Final, Literal, NotRequired, TypedDict, final, get_args
type Level = Literal["info", "warning", "error"]
MAX_MESSAGE: Final = 30
class Event(TypedDict):
level: Level
message: str
code: NotRequired[int]
@final
class Formatter:
def format(self, event: Event) -> str:
text = event["message"][:MAX_MESSAGE]
if "code" in event:
text += f" [code {event['code']}]"
return f"{event['level'].upper()}: {text}"
events: list[Event] = [
{"level": "info", "message": "server started"},
{"level": "error", "message": "disk full on /var/data/cache/images", "code": 28},
]
formatter = Formatter()
for event in events:
print(formatter.format(event))
print(get_args(Level.__value__), sorted(Event.__optional_keys__))
print(type(events[0]).__name__, getattr(Formatter, "__final__", False))
Run it with
python main.pyOutput
INFO: server started
ERROR: disk full on /var/data/cache/i [code 28]
('info', 'warning', 'error') ['code']
dict True- "code" in event is how you check a NotRequired key before reading it; mypy accepts the read after that test.
- get_args(Level.__value__) reads the allowed values from the alias, so they are written only once.
- The events are plain dicts: the TypedDict exists for the type checker.
- @final left only the flag __final__ = True; subclassing Formatter would still run.
Change it and run it
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.
Exercises
Exercise 1 of 2
A typed book record
Book records come from JSON. Make isbn optional in the TypedDict Book with NotRequired, keeping title, author and year required. Then fix label(book): it returns Title (Author, Year), followed by ISBN and the number only when the record has an isbn. load_books already parses the JSON.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.
Hints
Hint 1
from typing import NotRequired, then isbn: NotRequired[str].
Hint 2
Reading book["isbn"] on a record without it raises KeyError. Test "isbn" in book first.
Hint 3
Build the text without the ISBN, then add " ISBN " plus the number inside the if.
Show a solution
One way to solve it. Yours can look different and still pass the checks.
import json
from typing import NotRequired, TypedDict
class Book(TypedDict):
title: str
author: str
year: int
isbn: NotRequired[str]
def load_books(text: str) -> list[Book]:
books: list[Book] = json.loads(text)
return books
def label(book: Book) -> str:
text = f"{book['title']} ({book['author']}, {book['year']})"
if "isbn" in book:
text += f" ISBN {book['isbn']}"
return text
Run it on your computer
Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.py
import json
from typing import TypedDict
class Book(TypedDict):
title: str
author: str
year: int
isbn: str
def load_books(text: str) -> list[Book]:
books: list[Book] = json.loads(text)
return books
def label(book: Book) -> str:
return f"{book['title']} ({book['author']}, {book['year']}) ISBN {book['isbn']}"
test_main.py
from typing import is_typeddict
from main import Book, label, load_books
DATA = '[{"title": "Dune", "author": "Frank Herbert", "year": 1965, "isbn": "9780441013593"}, {"title": "Emma", "author": "Jane Austen", "year": 1815}]'
def test_keys():
"""title, author and year are required; isbn is optional"""
assert is_typeddict(Book), "Book should be a TypedDict"
got = sorted(Book.__required_keys__), sorted(Book.__optional_keys__)
assert got == (['author', 'title', 'year'], ['isbn']), f"required and optional keys are {got!r}"
def test_label_with_isbn():
"""A book with an isbn shows it at the end"""
got = label(load_books(DATA)[0])
assert got == "Dune (Frank Herbert, 1965) ISBN 9780441013593", f"label returned {got!r}"
def test_label_without_isbn():
"""A book without an isbn has no ISBN part"""
got = label(load_books(DATA)[1])
assert got == "Emma (Jane Austen, 1815)", f"label returned {got!r}"
On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.
Run the program:
python main.pyRun the checks (needs learnrun.py in the same folder):
python learnrun.py testDownload learnrun.pyExercise 2 of 2
Traffic lights
Define type Light = Literal["red", "green", "yellow"]. Make LIGHTS a Final[tuple[Light, ...]] and NEXT a Final[dict[Light, Light]] (red to green, green to yellow, yellow to red). next_light returns the next light. parse_light(text) strips and lower-cases the text, returns the matching Light from LIGHTS, and raises ValueError for anything else.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.
Hints
Hint 1
type Light = Literal["red", "green", "yellow"], then LIGHTS: Final[tuple[Light, ...]] = (...).
Hint 2
A plain NEXT = {...} is inferred as dict[str, str], and mypy then rejects returning NEXT[light] as a Light. Annotate it Final[dict[Light, Light]].
Hint 3
Loop over LIGHTS and return the light that equals the cleaned text: that way mypy knows the result is a Light.
Show a solution
One way to solve it. Yours can look different and still pass the checks.
from typing import Final, Literal
type Light = Literal["red", "green", "yellow"]
LIGHTS: Final[tuple[Light, ...]] = ("red", "green", "yellow")
NEXT: Final[dict[Light, Light]] = {"red": "green", "green": "yellow", "yellow": "red"}
def next_light(light: Light) -> Light:
return NEXT[light]
def parse_light(text: str) -> Light:
cleaned = text.strip().lower()
for light in LIGHTS:
if light == cleaned:
return light
raise ValueError(f"unknown light: {text!r}")
Run it on your computer
Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.py
from typing import Final, Literal
# Define the alias Light, and annotate the constants as Final.
LIGHTS = ("red", "green", "yellow")
NEXT = {"red": "green", "green": "yellow", "yellow": "red"}
def next_light(light: str) -> str:
return NEXT[light]
def parse_light(text: str) -> str:
return text
test_main.py
from typing import Final, get_args, get_origin
import main
def test_light_alias():
"""Light is an alias for Literal["red", "green", "yellow"]"""
light = getattr(main, "Light", None)
assert light is not None, "main.py defines no Light"
got = get_args(light.__value__)
assert got == ("red", "green", "yellow"), f"Light allows {got!r}"
def test_constants_are_final():
"""LIGHTS and NEXT are annotated with Final[...]"""
hints = getattr(main, "__annotations__", {})
for name in ("LIGHTS", "NEXT"):
assert get_origin(hints.get(name)) is Final, f"{name} is annotated as {hints.get(name)!r}, expected Final[...]"
def test_next_light():
"""red, green and yellow follow each other in a cycle"""
got = [main.next_light(light) for light in ("red", "green", "yellow")]
assert got == ["green", "yellow", "red"], f"next_light gave {got!r}"
def test_parse_light():
"""parse_light cleans the text: " Green " becomes "green\""""
got = main.parse_light(" Green ")
assert got == "green", f"parse_light(' Green ') returned {got!r}"
def test_parse_rejects_unknown():
"""parse_light("blue") raises ValueError"""
try:
main.parse_light("blue")
except ValueError:
return
assert False, "parse_light('blue') did not raise ValueError"
On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.
Run the program:
python main.pyRun the checks (needs learnrun.py in the same folder):
python learnrun.py testDownload learnrun.pyCommon mistakes
isinstance() with a TypedDict
from typing import TypedDict
class Movie(TypedDict):
title: str
print(isinstance({"title": "Alien"}, Movie))
What Python prints
TypeError: TypedDict does not support instance and class checksWhy, and the fix
A TypedDict value is a plain dict, so there is no class to check against. Check what you need yourself: isinstance(data, dict) and the keys you require, for example Movie.__required_keys__ <= data.keys(), and let mypy check the rest where the dict is built.
Leaving out a required key
from typing import TypedDict
class Movie(TypedDict):
title: str
year: int
m = Movie(title="Alien")
print(m["year"])
What Python prints
KeyError: 'year'Why, and the fix
Python builds the dict without complaint, and the error comes only when the key is read. mypy reports the construction line: Missing key "year" for TypedDict "Movie" [typeddict-item]. Supply the key, or, if it really may be missing, declare it NotRequired[int] and test "year" in m before reading it.
isinstance() with a Literal
from typing import Literal
Mode = Literal["r", "w"]
print(isinstance("r", Mode))
What Python prints
TypeError: Subscripted generics cannot be used with class and instance checksWhy, and the fix
A Literal is a set of values, not a class. To validate input at runtime, test membership in its values: from typing import get_args, then if mode in get_args(Mode):. Keep the values in one place, the Literal, and derive the check from it.
Python in the browser: Pyodide 314.0.7, MPL-2.0. Licence and source
Exit ticket
5 questions, no hints. Score 80% or more to complete the lesson.
Finish every activity above to unlock the exit ticket.