Skip to content
aviral gupta

// 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.

Lesson 4 of 6 in A2 Typing

You will be able to

  • Describe dict records with TypedDict, including total=False and NotRequired keys
  • Restrict values to fixed options with Literal, and predict how mypy narrows them
  • Mark names, methods and classes as final with Final and @final
  1. Warm-up · Activity 1 of 7

    Warm-up from lesson A2.1: which hint fits the JSON record {"title": "Alien", "year": 1979} best, using only built-in generics?

  2. 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"])
  3. 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]
  4. 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}
  5. Practice · Activity 5 of 7

    Match each construct to what it tells the type checker.

  6. 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)
  7. 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.py

Output

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
  1. Hint 1

    from typing import NotRequired, then isbn: NotRequired[str].

  2. Hint 2

    Reading book["isbn"] on a record without it raises KeyError. Test "isbn" in book first.

  3. 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.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Exercise 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
  1. Hint 1

    type Light = Literal["red", "green", "yellow"], then LIGHTS: Final[tuple[Light, ...]] = (...).

  2. 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]].

  3. 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.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Common 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 checks

Why, 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 checks

Why, 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.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

TypedDict: a dict with a known shape

class Movie(TypedDict): title: str; year: int describes dicts with fixed string keys, each with its own value type, such as records from JSON. mypy reports missing keys, unknown keys, misspelled keys and wrong value types. Every key is required unless marked NotRequired[...]; total=False makes all keys optional, and Required[...] opts one back in. At runtime a Movie is a plain dict: Movie(title=..., year=...) returns a dict, and isinstance() with a TypedDict raises TypeError.

Literal: exactly these values

def move(direction: Literal["up", "down"]) accepts only those two strings; mypy rejects move("left"). A variable assigned "up" is inferred as str, so passing it fails too, unless it is Final or annotated with the Literal type. After if level == "low": return, mypy narrows the rest of the function to Literal["high"]. At runtime, typing.get_args() returns the allowed values, which is handy for validating input.

Final and @final

MAX: Final = 3 tells mypy that the name must not be reassigned: MAX = 4 is reported as Cannot assign to final name "MAX". Final[int] works on class attributes too, which subclasses may not override. The @final decorator forbids subclassing a class, or overriding a method. None of this is checked at runtime: the reassignment and the subclass both run, and @final only sets __final__ = True.

Sources

Last reviewed September 29, 2026