Python Module 11 – Type hints and static type checking
TypedDict, Literal, NewType and enums
Type JSON-like dicts with TypedDict, allow only a few exact values with Literal and enums, keep IDs apart with NewType, and see what runs unchecked.
What you will learn
- Type JSON-like dictionaries with TypedDict, including keys that may be missing
- Limit a value to a few exact constants with a Literal type or an enum
- Create distinct ID types with NewType so that swapped arguments are reported
- Explain what each of the four checks before a run and what it leaves unchecked at run time
- Choose between a Literal type and an enum for values that travel through JSON
Before you start
What you’ll learn · Before you start
What you will learn
- Type JSON-like dictionaries with TypedDict, including keys that may be missing
- Limit a value to a few exact constants with a Literal type or an enum
- Create distinct ID types with NewType so that swapped arguments are reported
- Explain what each of the four checks before a run and what it leaves unchecked at run time
- Choose between a Literal type and an enum for values that travel through JSON
Before you start
On this page
An API sends an order as {"id": "A-101", "amount": 49900, "status": "paid"}, and typed as a plain
dict[str, object], none of those keys means anything to a type checker: A program such as mypy or pyright that reads Python source without running it and reports every call, return and assignment that breaks a type hint, before the program runs. More in “Why type hints help (and what they don't do)”. Four tools
let a checker understand such data more precisely. TypedDict: A class from typing that gives each key of a dictionary its own type, such as id: str and amount: int, for JSON-like records. The value is still a plain dict at run time, and Python checks none of its keys. gives every key of a
dictionary its own type, as JSON-like records need. A Literal type: A type that allows only the exact values it lists: Literal["asc", "desc"] accepts those two strings and the checker refuses every other. At run time the value is the plain string, and nothing is checked. allows only a few
exact values, such as "asc" and "desc". NewType: A separate name for an existing type, as in UserId = NewType("UserId", int). The checker keeps a UserId apart from a plain int and from other NewTypes, while at run time UserId(42) is the int 42 itself. makes a separate name for an existing
type, so that a user ID and an order ID that are both integers cannot be swapped. Enums list named constants that
still exist while the program runs.
mypy uses all four to report mistakes before the code runs. At run time, though, a TypedDict value is a plain dictionary, a Literal value a plain string and a NewType value a plain integer, so data from outside still needs checking. The recorded runs below show both sides.
TypedDict gives each key of a dictionary its own type
An API that returns orders sends the same keys every time: an id, an amount, a status and sometimes a coupon. As
dict[str, object], every value is just an object to the checker, and every use of a value needs a cast or a check.
A TypedDict class lists the keys and the type of each one, and NotRequired marks a key that may be missing. The
class is written like a dataclass, but it describes a dictionary instead of creating a new kind of object.
"""An order as a shop's API returns it, typed with TypedDict and Literal."""
from typing import Literal, NotRequired, TypedDict
class Order(TypedDict):
id: str
amount: int # in the currency's smallest unit, such as cents or paise
status: Literal["created", "paid", "failed"]
coupon: NotRequired[str]
def total(orders: list[Order]) -> int:
return sum(order["amount"] for order in orders)
paid: Order = {"id": "A-101", "amount": 49_900, "status": "paid"}
typo: Order = {"id": "A-102", "amount": 9_900, "status": "payed"}
short: Order = {"id": "A-103", "status": "created"}
print(type(paid).__name__, paid)
print("required keys:", sorted(Order.__required_keys__))
print("optional keys:", sorted(Order.__optional_keys__))
print("the typo is stored:", typo["status"])
try:
print(total([paid, typo, short]))
except KeyError as error:
print("KeyError:", error) Output
dict {'id': 'A-101', 'amount': 49900, 'status': 'paid'}
required keys: ['amount', 'id', 'status']
optional keys: ['coupon']
the typo is stored: payed
KeyError: 'amount'
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 orders.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
- The value is an ordinary
dict. A TypedDict class creates no new kind of object, so building or receiving an order costs nothing extra and checks nothing. - The class keeps its lists of required and optional keys, which tools can read while the program runs.
- The misspelt status
"payed"is stored without complaint, and the order without an amount fails only whentotal()reads it, far from the line that made the mistake.
The checker reads the same file without running it; install the same version with pip install mypy==2.4.0 to
repeat any of these checks:
from session import run
# The misspelt status and the order without an amount, as mypy reads orders.py.
run("""
mypy --version
mypy --strict orders.py
""", folder="../orders") Output
$ mypy --version mypy 2.4.0 (compiled: yes) $ mypy --strict orders.py orders.py:17: error: Incompatible types (expression has type "Literal['payed']", TypedDict item "status" has type "Literal['created', 'paid', 'failed']") [typeddict-item] orders.py:18: error: Missing key "amount" for TypedDict "Order" [typeddict-item] Found 2 errors in 1 file (checked 1 source file) [exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_orders.py
mypy points at the two lines that made the mistakes, before the program has run once. Step through both readings of the file side by side:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- Two readers. Python will run orders.py, and mypy --strict will read it without running it. The file has two mistakes, a misspelt status on line 17 and an order without an amount on line 18.
- An ordinary dict. Line 16 builds paid, annotated as an Order. Python makes the plain dict that the literal describes, which is why the run printed dict, and checks none of its keys.
- The typo is stored. Line 17 stores the status "payed". Nothing compares it with the three allowed strings, and the run printed: the typo is stored: payed.
- A short order. Line 18 builds an order with no amount key. Python builds it as happily as the others, because a TypedDict checks nothing when the program runs.
- The KeyError, far away. Line 25 calls total(), which reads the amount of every order, and the short one has none. The run stopped with KeyError 'amount', inside total(), far from the line that made the mistake.
- mypy reads line 17. mypy reads all 27 lines and runs none of them. The status of the order on line 17 is "payed", which is not one of the three Literal strings of the status key, so it reported typeddict-item.
- mypy reads line 18. The order on line 18 has no amount, and amount is a required key of Order, so mypy reported a missing key for that line too.
- The same two mistakes. Python never noticed the typo and met the missing key late, as a KeyError. mypy reported both lines with their numbers before the program ran once.
Checkpoint
Check: TypedDict keys and their types
Literal types and enums allow only a few exact values
A sort order, a mode or a status usually has a handful of valid values. A parameter typed Literal["asc", "desc"]
accepts exactly those two strings and nothing else. Enums give the same protection with a class whose members exist
at run time, and calling the class turns text into a member or refuses it.
"""Two ways to allow only a few values: a Literal type and an enum."""
from enum import StrEnum
from typing import Literal, get_args
SortOrder = Literal["asc", "desc"]
def sort_prices(prices: list[int], order: SortOrder = "asc") -> list[int]:
return sorted(prices, reverse=order == "desc")
class Status(StrEnum):
CREATED = "created"
PAID = "paid"
FAILED = "failed"
def can_refund(status: Status) -> bool:
return status is Status.PAID
print(sort_prices([300, 100, 200], "desc"))
print(sort_prices([300, 100, 200], "descending"))
print("allowed:", get_args(SortOrder))
print(can_refund(Status("paid")), can_refund("paid"))
try:
Status("payed")
except ValueError as error:
print("ValueError:", error) Output
[300, 200, 100]
[100, 200, 300]
allowed: ('asc', 'desc')
True False
ValueError: 'payed' is not a valid Status
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 choices.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
from session import run
# The typo in the sort order and the plain string passed where a Status is expected.
run("""
mypy --version
mypy --strict choices.py
""", folder="../choices") Output
$ mypy --version mypy 2.4.0 (compiled: yes) $ mypy --strict choices.py choices.py:23: error: Argument 2 to "sort_prices" has incompatible type "Literal['descending']"; expected "Literal['asc', 'desc']" [arg-type] choices.py:26: error: Argument 1 to "can_refund" has incompatible type "str"; expected "Status" [arg-type] Found 2 errors in 1 file (checked 1 source file) [exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_choices.py
The typo "descending" sorted the prices the wrong way without any error, because the function only compares its
argument with "desc". The checker reports the call. can_refund("paid") printed False at run time: a member of a
StrEnum is equal to its string, but is compares identity, and the checker reports the plain string as well.
Status("payed") shows what enums add: the lookup raises ValueError while the program runs, which no annotation
does.
Checkpoint
Check: Literal types and enums
NewType keeps two kinds of int apart
Here a user ID and an order ID, both plain ints, are swapped in one call:
"""Two kinds of ID that are both int at run time, kept apart by NewType."""
from typing import NewType
UserId = NewType("UserId", int)
OrderId = NewType("OrderId", int)
def cancel(user: UserId, order: OrderId) -> str:
return f"user {user} cancels order {order}"
user = UserId(42)
order = OrderId(1001)
print(cancel(user, order))
print(cancel(order, user))
print(type(user).__name__, user == 42, user + 1) Output
user 42 cancels order 1001 user 1001 cancels order 42 int True 43
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 ids.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
from session import run
# Both arguments of the swapped call, as mypy reads ids.py.
run("""
mypy --version
mypy --strict ids.py
""", folder="../ids") Output
$ mypy --version mypy 2.4.0 (compiled: yes) $ mypy --strict ids.py ids.py:15: error: Argument 1 to "cancel" has incompatible type "OrderId"; expected "UserId" [arg-type] ids.py:15: error: Argument 2 to "cancel" has incompatible type "UserId"; expected "OrderId" [arg-type] Found 2 errors in 1 file (checked 1 source file) [exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_ids.py
UserId(42) hands back the integer 42 itself, so the swapped call ran and printed nonsense. To mypy, though, a
UserId and an OrderId are two different subtypes of int, so passing one where the other is expected is an
error. NewType suits values that share a representation but must never mix: IDs, units, or text that has already
been cleaned. Wrap a value once, where it enters the program, and the checker follows it from there.
Swap the IDs back
Press Edit on ids.py, change the second call to cancel(user, order) and run it: both lines now agree. Nothing
in what Python printed before had pointed at the swapped call; only the checker’s report did.
Checkpoint
Check: NewType for values that must not mix
Only the enum checks values while the program runs
Before a run the checker reads all four kinds of annotation; during it, only one of them checks anything:
What each annotation becomes before and during a run
Text description of the diagram
The diagram starts with your annotations: an Order TypedDict, a SortOrder Literal type, a UserId NewType and a Status enum.
- One arrow, labelled "before it runs", leads to the type checker, which reads every annotation and reports mistakes before the program runs.
- A second arrow, labelled "while it runs", leads to the running program, where an Order is a plain dict, a SortOrder value is a plain str, a UserId is a plain int and a Status is an enum member.
- From the running program, a last arrow labelled "only the enum checks values" leads to Status("payed") raising ValueError: of the four, only the enum refuses a wrong value while the program runs.
| Tool | What it is while the program runs | Typical use |
|---|---|---|
| TypedDict | A plain dict, and nothing is checked |
JSON objects and API responses |
| Literal | The plain value, such as a str, and nothing is checked |
Modes, flags and sort orders |
| NewType | The plain value of the base type, and nothing is checked | IDs and units that must not mix |
| Enum | An enum member: Status(value) refuses unknown values |
States with behaviour of their own |
Annotations are read by the checker, and only the enum does any checking of its own while the program runs. Data from a file, a form or an API therefore still needs validation at the edge of the program, which a later lesson of this module builds.
Checkpoint
Check: What runs unchecked
Check yourself
Practice that adapts to you
Practise this lesson
8 questions about 11 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
TypedDict in depth: optional, read-only and nested keys, checked by mypy Pro: opens with a Pro, Premium or Ultimate pass
A TypedDict describes more than a flat list of keys: keys that may be missing, keys that must not change, nested records and generic pages of results. This part runs each one through Python and through mypy, including the one mistake with optional keys that mypy lets through.
In this part:
- The optional-key read that passes mypy and fails at run time, reproduced
- Which TypedDicts fit where: extra keys, exact key types and ReadOnly, an invariant reproduced
- Read-only keys, and why a TypedDict is not a dict[str, object]
- Exercise: typed payments
- Type a payment API's responses with TypedDict and Literal
- Results of the sample tests
1 diagram · 6 runnable examples (Python) · 1 exercise with sample tests · about 11 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Literal, LiteralString and NewType at work: JSON, an injection drill and a measured cost Pro: opens with a Pro, Premium or Ultimate pass
Literal values, enums and NewTypes meet the outside world in JSON, in database queries and in hot loops. This part shows what each one costs and protects there, with a decision table for statuses that travel through APIs.
In this part:
- Literal or enum for a status that travels through JSON, a trade-off table
- LiteralString and query strings: an injection drill
- Where NewType stops: arithmetic, isinstance() and wrapping again
- What NewType costs at run time, measured
6 runnable examples (Python) · about 8 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Interview questions
-
Fresher to mid · Warm-up
When would you describe an API response with a TypedDict rather than a dataclass?
A TypedDict describes a dictionary: the value stays the plain dict that json.loads() returned, nothing is copied, and code that reads it with ["key"] keeps working, while the checker knows the type of every key. A dataclass makes a new class: each object has to be built from the data, attributes replace keys, and the class can have methods, defaults, validation in __post_init__ and frozen instances. Choose a TypedDict when the data stays shaped like a dictionary, for example when it is passed on to another API or written back as JSON, or when existing code already reads dictionaries. Choose a dataclass when the record is a model of your own program with behaviour. Neither one checks the data at run time, apart from what __post_init__ does, so untrusted JSON needs validation either way.
-
Mid to senior
Your service types a payment API's response with a TypedDict, mypy passes in CI, and yet production logs show KeyError: 'coupon'. How can both be true, and what do you change so that this kind of failure cannot come back?
-
Mid to senior
An order status travels from a JSON API through your service into a database column. Would you type it as a Literal of strings or as an enum? Defend the choice, and say where the conversion between text and type happens.
Key takeaways
- TypedDict gives each key of a dictionary its own type, and the value is still a plain
dictat run time. - A Literal type allows only a few exact values, and an enum does the same with members that exist while the program runs.
- NewType makes a separate type for the checker, so two kinds of ID that are both ints cannot be swapped unnoticed.
- Only the enum refuses a wrong value while the program runs, so data from outside still needs validation.
Terms in this lesson
- Literal type
- A type that allows only the exact values it lists: Literal["asc", "desc"] accepts those two strings and the checker refuses every other. At run time the value is the plain string, and nothing is checked.
- NewType
- A separate name for an existing type, as in UserId = NewType("UserId", int). The checker keeps a UserId apart from a plain int and from other NewTypes, while at run time UserId(42) is the int 42 itself.
- TypedDict
- A class from typing that gives each key of a dictionary its own type, such as id: str and amount: int, for JSON-like records. The value is still a plain dict at run time, and Python checks none of its keys.
References
- typing: TypedDict (Python Software Foundation)
- typing: Literal (Python Software Foundation)
- typing: NewType (Python Software Foundation)
- typing: LiteralString (Python Software Foundation)
- Typing specification: TypedDict (The Python Typing Team)
- Typing specification: Literal (The Python Typing Team)
- PEP 655: Marking individual TypedDict items as required or potentially-missing (Python Software Foundation)
- PEP 705: TypedDict: Read-only items (Python Software Foundation)
- PEP 728: TypedDict with Typed Extra Items (Python Software Foundation)
- PEP 675: Arbitrary Literal String Type (Python Software Foundation)
- enum: support for enumerations (Python Software Foundation)
- mypy documentation: TypedDict (The mypy project)
- mypy documentation: Literal types and Enums (The mypy project)
- Pyright documentation: type checking features (Microsoft)
- Pyright documentation: configuration (reportTypedDictNotRequiredAccess) (Microsoft)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress