Command Palette

Search for a command to run...

Hectal
PHASE 10Intermediate Python 3.11+ ~27 min· topic 5 of 9

Topic 10.5

TypedDict, NamedTuple, Literal and Final

In one line

These tools describe specific shapes: TypedDict labels the keys of a dictionary, NamedTuple gives tuple fields names, Literal allows only listed values, Final marks a constant, and Self, Annotated and @override cover methods that return their own class, extra metadata and overriding.

Think of it like this

A paper form with boxes. A blank sheet lets you write anything anywhere. A form says: name goes here, age goes there, and "title" must be one of Mr, Ms, Dr. Hints can describe the shape of data the same way, so mistakes show up before anyone fills in the form wrongly.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

`TypedDict`
A dictionary type with named keys and a type for each value.
`NotRequired`
Marks a TypedDict key that may be missing.
`Literal`
A type that allows only the listed values, like Literal["a", "b"].
`NamedTuple`
A tuple whose items have names and types.
`Final`
A label for a name that should never be reassigned.
`Self`
A hint meaning "an instance of the same class as this one".
`Annotated`
A type plus extra notes that libraries can read; checkers ignore the notes.
`@override`
A decorator marking that a method replaces one from a parent class.
Tag (discriminator)
A field such as role whose value tells you which shape of data you have.

Step by step

01TypedDict: a shape for a dictionary

Write it like a class with annotated fields. This one is the shape of one chat message. The role is a Literal so only three values are allowed. name is optional.

main.pywhole filepython
from typing import Literal, NotRequired, TypedDict

Role = Literal["user", "assistant", "system"]

class Message(TypedDict):
    role: Role
    content: str
    name: NotRequired[str]

msg: Message = {"role": "user", "content": "Hello"}

02What the checker catches

A checker rejects {"role": "user"} (missing content), {"rol": ...} (unknown key), and "year": "2009" where an int is expected. Python runs all of them. The run-time example shows that even Message(role="robot", content="x") is accepted.

03NamedTuple: readable small records

Point(3) fills y with its default. You can unpack x, y = p, index p[0], and call p._asdict(). Tuples cannot be changed, so a NamedTuple can be a dictionary key as long as every field is itself hashable (an int or str, not a list).

04Final and final

MAX_TOKENS: Final = 4096 makes the checker object to a later MAX_TOKENS = 1. Python does not. Treat Final as documentation that the build enforces; the capital letters are the Python convention for the same thing.

05Self: methods that return their own class

A builder method returns self so calls can be chained. If it is hinted -> Builder, then after HtmlBuilder().add("a") the checker thinks you have a plain Builder and the .bold() call is an error. -> Self fixes it: the return type follows the subclass.

06@override and Annotated

Put @override above a method that replaces a parent's method. If a refactor renames speak in the parent, the checker flags every child that still says @override def speak. At run time, @override just sets the attribute __override__ = True.

Annotated[int, "years"] is still an int to the checker. The extra items are for tools. get_type_hints(..., include_extras=True) keeps them; without it, they are stripped.

07The AI connection

Every LLM API speaks in dictionaries with a fixed shape: a list of messages with a role and content, tool definitions, tool results. Modelling them as TypedDict with Literal roles gives you checker help for free and is exactly how the official SDKs (the libraries each AI company ships for calling its service) type their requests. For data you must validate at run time, the same shape becomes a Pydantic model (Topic 10.7).

Try it yourself

  1. 1

    Add an optional key

    In the first example, add tool_call_id: NotRequired[str] to Message and print sorted(Message.__optional_keys__). Both optional keys appear.

  2. 2

    Chain a method

    In the second example, change -> Self to -> Builder on add and run again. It still works at run time (Python ignores hints), but a checker would now reject .bold() after .add(...). Put Self back.

Code & diagrams

TypedDict, Literal, NamedTuple and Final at run time Python 3.11+ New tab

At run time a TypedDict is a dict, nothing is enforced, and the class remembers which keys are required.

Sign in to run this example in your browser.

Expected output

{'role': 'user', 'content': 'Hello'} dict
is a plain dict: True
bad role is accepted at run time: {'role': 'robot', 'content': 'x'}
required: ['content', 'role']
optional: ['name']
Options required: ['model']
roles: ('user', 'assistant', 'system')
Point(x=3, y=0) 3 0 {'x': 3, 'y': 0}
unpacked: 3 0
MAX_TOKENS = 4096
MAX_TOKENS now 1
Self, @override and Annotated Python 3.12+ New tab

@override needs Python 3.12; Self needs 3.11. Annotated extras are visible only when asked for.

Sign in to run this example in your browser.

Expected output

['a', '<b>', 'b']
Meow | __override__ = True
{'age': typing.Annotated[int, 'years', 0]}
metadata: ('years', 0)
plain: {'age': <class 'int'>}
What mypy says about a bad messagebash

The Movie TypedDict from the checker session of Topic 10.3: a wrong value type is reported with the key name.

mypy more.py

Output

more.py:31: error: Incompatible types (expression has type "str", TypedDict item "year" has type "int")  [typeddict-item]
more.py:45: error: Argument 1 to "label" has incompatible type "Literal['system']"; expected "Literal['user', 'assistant']"  [arg-type]
A tagged union of TypedDicts: LLM reply blocks Python 3.11+ New tab

A model reply is a list of blocks, each with a Literal type tag. After the checker sees block["type"] == "text" it knows the block is a TextBlock. assert_never makes the checker fail if a third block type is added and render forgets it. At run time isinstance does not work on a TypedDict.

Sign in to run this example in your browser.

Expected output

Let me check the weather.
[calls weather with {'city': 'Pune'}]
TypeError: TypedDict does not support instance and class checks

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Override a method that does not exist

Mark a method @override but misspell its name.

main.pywhole filepython
from typing import override

class Animal:
    def speak(self) -> str:
        return "..."

class Cat(Animal):
    @override
    def spek(self) -> str:        # typo
        return "Meow"

print(Cat().speak())
terminal
$ python main.py && mypy main.py
── what you'll see ──
...
main.py:9: error: Method "spek" is marked as an override, but no base method was found with this name [misc]
Found 1 error in 1 file (checked 1 source file)

Myth vs fact

Myth

A TypedDict validates its keys when I create it.

Fact

No. At run time it is an ordinary dict. The example creates a message with the role "robot" without a complaint. Use Pydantic when you need run-time validation.

Myth

Final makes a constant that cannot change.

Fact

Only for the checker. Python has no real constants; the assignment MAX_TOKENS = 1 ran fine above.

Myth

NamedTuple and dataclass are the same.

Fact

A NamedTuple is an immutable tuple (it unpacks, indexes, hashes). A dataclass is a normal class with generated methods, and mutable unless you freeze it.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    For API payloads, a tagged union of TypedDicts works well: each shape has a Literal type field ({"type": "text", ...} or {"type": "tool_use", ...}) and the checker narrows to the right shape after you test the tag. match on the dict (Topic 10.8) uses the same tag.

  • ▸

    Required and NotRequired arrived in 3.11. For older versions the same names live in typing_extensions, a backport package that carries newer typing features to older Pythons.

  • ▸

    Literal values must be literals: str, int, bool, bytes, None or enum members. For a dynamic set of choices use Enum (Topic 8.9) or a StrEnum.

  • ▸

    @override is a typing tool and its run-time effect (__override__ = True) is a stamp, not a check. @typing.final on a class or method is similar: the checker forbids subclassing or overriding, Python does not.

  • ▸

    A TypedDict **cannot be used with isinstance**: isinstance(d, Message) raises TypeError: TypedDict does not support instance and class checks, because the class is erased to a plain dict. To check a shape at run time, write the checks yourself or validate with Pydantic (Topic 10.7). Python 3.13 added ReadOnly[...] for keys the code must not assign (Cfg.__readonly_keys__), and 3.12 added **kwargs: Unpack[Opts] so keyword arguments can be typed by a TypedDict. A TypedDict is also structural: any dict literal with the right keys fits, but a general dict[str, str] does not fit a TypedDict, and a TypedDict fits Mapping[str, object] but not dict[str, object] (writing arbitrary values through a dict could break the shape).

  • ▸

    Self is also the right return type of alternative constructors: @classmethod def from_dict(cls, data: dict) -> Self returns a Sub when called on Sub, so subclasses do not need to re-declare it. Before 3.11 you wrote a bound TypeVar for the same job. Annotated is the hinge between the checker and libraries: Annotated[int, Field(ge=0)] is an int to mypy and a validated field to Pydantic, and FastAPI reads Annotated[int, Query(gt=0)] to build request parsing and the OpenAPI docs.

Remember this

  1. 1

    **TypedDict** describes a dictionary (Topic 4.4) with known keys, each with its own type. It is how you label data that arrives as dictionaries, such as a chat message or a web API reply, without turning it into a class. You write it like a small class with one field per line: a role that is a str and a content that is a str.

  2. 2

    At run time a TypedDict is a plain dict (check with isinstance(msg, dict)), so nothing is enforced. The checker, though, rejects a missing key, a misspelled key or a wrong value type.

  3. 3

    By default every key of a TypedDict is required. NotRequired[str] (Python 3.11) marks one key as optional. Writing total=False on the class makes every key optional, and Required[...] then marks the ones that must stay. The class keeps the two sets in __required_keys__ and __optional_keys__.

  4. 4

    **Literal** allows only specific values: Literal["user", "assistant", "system"]. This is exactly how chat-message roles or modes such as "fast" and "slow" are described. Because the allowed list is known, a checker can warn about the typo "asistant" and, with assert_never (Topic 10.8), can tell you when you forgot to handle one of them.

  5. 5

    **NamedTuple** is a tuple (Topic 4.3) whose positions have names and types. You write class Point(NamedTuple) with fields x: int and y: int = 0, one per line. It unpacks like a tuple, indexes like a tuple, and reads like an object (p.x). It cannot be changed after creation (immutable), it is light, and it suits small records.

  6. 6

    Dataclasses (Topic 8.8) are the choice when you need your own methods, changeable fields or inheritance.

  7. 7

    **Final** tells the checker that a name must not be reassigned: MAX_TOKENS: Final = 4096. Python itself still lets you reassign it (there are no real constants in Python); the checker will object. The same module also has @final to forbid subclassing (building a new class on top of this one, Topic 8.5) or overriding.

  8. 8

    Three more helpers.

  9. 9

    **Self** (3.11) means "the same class as the object that called the method". A method that returns self so calls can be chained (a.add().add()) then keeps the exact class.

  10. 10

    **Annotated[int, "years"]** attaches extra notes (metadata) to a type without changing the type. Checkers ignore the notes; libraries read them (Pydantic uses them for rules like Field(ge=0), meaning "greater than or equal to 0").

  11. 11

    **@override** (3.12) marks a method that is meant to replace a method of the parent class. A decorator is a line starting with @ above a function that adds behaviour to it (Topic 9.6). The checker fails if the parent has no such method, which catches typos and renames.

Explain it without notes

01

What is a TypedDict at run time, and what does it give you at all?

02

When would you use Literal instead of str?

03

Why is -> Self better than naming the class when a method returns self?

04

How does a NamedTuple differ from a plain tuple and from a dataclass?

05

Can you validate that a dictionary matches a TypedDict at run time with isinstance? What would you do instead?

Practice

01

Define a TypedDict named ToolCall with name: str and arguments: dict[str, str], create one, and print its __required_keys__ sorted.

02

Define a NamedTuple Rgb with r, g, b ints and print Rgb(255, 0, 0) and its g value.

03

Define a type Mode = Literal["fast", "slow"] and print its allowed values using get_args.

Trade-offs

  • ↔

    TypedDict versus dataclass versus Pydantic. TypedDict labels data you already hold as dicts (JSON) with no conversion. A dataclass gives behaviour. Pydantic validates. Pick by where the data comes from and what you need to do with it.

  • ↔

    Literal is strict and brittle. Adding a new allowed value touches every place that handles them, which is the point (exhaustive checks) but costs effort.

  • ↔

    Hints only. None of these tools stops a wrong value while running; they move mistakes from production to your editor.

Done when you can

  • Done when you can describe a dictionary's shape with a TypedDict, including an optional key.

  • Done when you can restrict a value with Literal.

  • Done when you can write a NamedTuple and say how it differs from a dataclass.

  • Done when you can use Final, Self and @override and say what they do at run time.

  • Done when you can read extra metadata from Annotated.