Chapter 20 — Typed Dictionaries and Validated Data Boundaries
1. Opening problem
An HTTP request body arrives as JSON:
{
"email": "steve@example.com",
"name": "Steve",
"age": 44
}
After parsing:
payload = json.loads(
request_body
)
the runtime object is ordinary Python data.
A TypedDict can describe an expected dictionary shape:
from typing import TypedDict
class UserPayload(
TypedDict
):
email: str
name: str
age: int
But the annotation does not validate the JSON.
This chapter explains how TypedDict helps static code while runtime parsers protect system boundaries.
2. What TypedDict is
class UserPayload(
TypedDict
):
email: str
name: str
age: int
Static tools expect:
- specific string keys;
- specific value types;
- required keys by default.
Runtime values are normal dictionaries:
payload: UserPayload = {
"email": "steve@example.com",
"name": "Steve",
"age": 44,
}
print(
type(payload)
)
The result is dict.
TypedDict does not create instances with methods or runtime enforcement.
3. Why not dict[str, object]?
def send_welcome(
user: dict[str, object],
) -> None:
email = user["email"]
The checker only knows that email is object.
With TypedDict:
def send_welcome(
user: UserPayload,
) -> None:
email = user["email"]
The checker knows email is str.
TypedDict improves static precision while preserving dictionary runtime shape.
4. Required keys
Default:
class UserPayload(
TypedDict
):
email: str
name: str
Both keys are required statically.
This should fail type checking:
user: UserPayload = {
"email": "steve@example.com",
}
At runtime, Python still creates the dictionary.
The checker—not the interpreter—enforces the declared shape.
5. Optional keys with NotRequired
from typing import (
NotRequired,
TypedDict,
)
class UserPayload(
TypedDict
):
email: str
name: str
nickname: NotRequired[str]
nickname may be absent.
This is different from:
nickname: str | None
That key is required but its value may be None.
Compare:
{}
versus:
{
"nickname": None,
}
Missing and explicit null are different states.
6. total=False
class UserPatch(
TypedDict,
total=False,
):
email: str
name: str
nickname: str | None
Every key is potentially missing.
This fits PATCH-like payload descriptions.
For mixed required and optional fields, per-key qualifiers are often clearer.
7. Required
from typing import Required
class ImportRow(
TypedDict,
total=False,
):
external_id: Required[str]
email: str
name: str
external_id is required; the others are optional.
Use Required and NotRequired to communicate each key's semantics explicitly.
8. ReadOnly
from typing import ReadOnly
class UserView(
TypedDict
):
user_id: ReadOnly[int]
email: str
A type checker should reject:
user["user_id"] = 100
But ReadOnly is not runtime protection.
user["user_id"] = 100
still mutates an ordinary dictionary at runtime unless another mechanism prevents it.
Use immutable domain objects or read-only mappings when runtime protection matters.
9. Inheritance
class Identified(
TypedDict
):
id: int
class UserPayload(
Identified
):
email: str
name: str
TypedDict inheritance can reuse shape definitions.
Avoid deep inheritance hierarchies that make key requirements difficult to understand.
Composition is not directly represented inside a dictionary shape, but nested typed dictionaries may be clearer:
class AddressPayload(
TypedDict
):
city: str
postal_code: str
class UserPayload(
TypedDict
):
email: str
address: AddressPayload
10. Generic TypedDict
Modern syntax:
class ApiResponse[T](
TypedDict
):
data: T
request_id: str
Usage:
response: ApiResponse[
UserPayload
]
Compatibility syntax can inherit from Generic[T] in older codebases.
Generic typed dictionaries are useful for transport envelopes whose payload type varies.
Do not hide every response in a universal envelope if different endpoints have different error and metadata semantics.
11. Typed keyword arguments with Unpack
from typing import Unpack
class EmailOptions(
TypedDict,
total=False,
):
subject: str
urgent: bool
reply_to: str
def send_email(
recipient: str,
**options: Unpack[
EmailOptions
],
) -> None:
...
Static tools understand accepted keyword names and values.
Call:
send_email(
"steve@example.com",
subject="Welcome",
urgent=True,
)
This is more precise than:
def send_email(
recipient: str,
**options: object,
) -> None:
...
However, an explicit keyword-only signature may still be clearer:
def send_email(
recipient: str,
*,
subject: str = "",
urgent: bool = False,
reply_to: str | None = None,
) -> None:
...
Use Unpack[TypedDict] when options are reused or forwarded and dictionary shape is meaningful.
12. Runtime introspection
Typed dictionary classes expose metadata such as:
UserPatch.__required_keys__
UserPatch.__optional_keys__
Newer versions also expose read-only and mutable key sets.
Cautions:
- string or deferred annotations can affect introspection;
- metadata is not validation;
- inheritance can make
__total__misleading; - framework behavior should be tested across supported Python versions.
Use official introspection helpers when possible.
13. Functional syntax
Class syntax is normally preferred:
class Movie(
TypedDict
):
title: str
year: int
Functional syntax can help with keys that are not valid identifiers:
Movie = TypedDict(
"Movie",
{
"title": str,
"release-year": int,
},
)
Prefer class syntax for readability unless field names require the functional form.
Avoid deprecated functional patterns with omitted or None field definitions.
14. TypedDict is structural
Two independently declared typed dictionaries with compatible shape can be structurally compatible under typing rules.
class UserName(
TypedDict
):
name: str
class ProductName(
TypedDict
):
name: str
This can be useful but may also permit accidental conceptual mixing.
For important domain distinctions, use named objects or value types rather than relying only on dictionary shape.
15. Width subtyping and extra keys
A dictionary with additional keys may sometimes be accepted where a narrower typed dictionary is expected, depending on mutability and required-key rules.
This is more subtle than TypeScript object assignment.
Why?
The called function may mutate or delete keys.
Example:
class HasName(
TypedDict
):
name: str
A function accepting HasName could assign a new string safely, but optionality and read-only status complicate compatibility.
Rely on the type checker rather than guessing structural assignment rules.
16. Typed dictionary versus dataclass
Use TypedDict when:
- runtime value should remain a dictionary;
- interacting with JSON-shaped data;
- adapting third-party dictionaries;
- using
**kwargsshape typing; - minimal runtime behavior is desired.
Use a dataclass when:
- construction should be controlled;
- methods are useful;
- runtime type identity matters;
- equality or representation matters;
- invariants should be protected;
- immutable values are useful.
A common architecture uses both:
external dictionary
↓
validated parser
↓
domain dataclass or value object
17. The boundary principle
External data is untrusted or weakly typed.
Sources include:
- HTTP requests;
- message queues;
- environment variables;
- files;
- database rows;
- command-line arguments;
- third-party APIs;
- user input.
A type annotation does not transform external data into trusted internal data.
Use a boundary parser:
def parse_create_user(
payload: dict[str, object],
) -> CreateUserCommand:
...
Once parsing succeeds, internal code receives a strong model.
18. Explicit parsing
from dataclasses import dataclass
@dataclass(frozen=True)
class CreateUserCommand:
email: EmailAddress
name: str
age: int
Parser:
def parse_create_user(
payload: dict[str, object],
) -> CreateUserCommand:
email_value = payload.get(
"email"
)
name_value = payload.get(
"name"
)
age_value = payload.get(
"age"
)
if not isinstance(
email_value,
str,
):
raise ValueError(
"email must be a string"
)
if not isinstance(
name_value,
str,
):
raise ValueError(
"name must be a string"
)
if (
isinstance(
age_value,
bool,
)
or not isinstance(
age_value,
int,
)
):
raise ValueError(
"age must be an integer"
)
name = name_value.strip()
if not name:
raise ValueError(
"name must not be blank"
)
if not 0 <= age_value <= 130:
raise ValueError(
"age is outside range"
)
return CreateUserCommand(
email=EmailAddress(
email_value
),
name=name,
age=age_value,
)
The application service can now trust the command's invariants.
19. Why cast is not parsing
Dangerous:
from typing import cast
payload = cast(
UserPayload,
json.loads(body),
)
This tells the checker to trust the programmer.
It does not verify:
- object is a dictionary;
- keys exist;
- values have correct types;
- strings are normalized;
- numbers satisfy ranges;
- unknown fields are allowed;
- nested shapes are valid.
Use cast only after another mechanism has established the invariant.
20. Validation error modeling
A parser can raise one ValueError, but production APIs often need structured errors.
from dataclasses import dataclass
@dataclass(frozen=True)
class FieldError:
field: str
code: str
message: str
Parser result:
@dataclass(frozen=True)
class ValidationFailure:
errors: tuple[
FieldError,
...
]
Questions:
- fail fast or collect all errors?
- expose internal messages?
- translate messages?
- include rejected values?
- redact secrets?
- map errors to HTTP status?
- preserve machine-readable codes?
Validation architecture is part of the public API.
21. Unknown keys
Decide whether to:
- reject unknown keys;
- ignore them;
- preserve them;
- log them;
- accept namespaced extensions.
Strict rejection catches client mistakes but can reduce forward compatibility.
Ignoring keys supports evolution but may hide spelling errors.
Example strict parser:
ALLOWED_KEYS = {
"email",
"name",
"age",
}
unknown = (
payload.keys()
- ALLOWED_KEYS
)
if unknown:
raise ValueError(
f"Unknown fields: "
f"{sorted(unknown)}"
)
Document the policy.
22. PATCH semantics
class UserPatch(
TypedDict,
total=False,
):
email: str
nickname: str | None
Static description:
emailmay be missing;nicknamemay be missing;- when present,
nicknamemay beNone.
Runtime parser should preserve:
- omitted field;
- explicit null;
- empty string;
- valid normal value.
Sentinel:
class _Missing:
__slots__ = ()
MISSING = _Missing()
Do not use .get() when missing and null differ.
23. Nested data
class AddressPayload(
TypedDict
):
street: str
city: str
postal_code: str
class UserPayload(
TypedDict
):
email: str
address: AddressPayload
Runtime parsing must recursively validate.
Avoid writing one enormous nested parser. Create small functions:
def parse_address(
value: object,
) -> Address:
...
def parse_user(
value: object,
) -> User:
...
Small parsers improve tests and error localization.
24. Response serialization
Internal domain objects should not automatically become public dictionaries.
Explicit serializer:
class UserResponse(
TypedDict
):
id: int
email: str
display_name: str
def serialize_user(
user: User,
) -> UserResponse:
return {
"id": user.user_id,
"email": str(
user.email
),
"display_name": (
user.display_name
),
}
Benefits:
- controls exposed fields;
- prevents secret leakage;
- stabilizes API shape;
- supports versioning;
- separates domain and transport models.
25. TypeScript comparison
TypeScript interface:
interface UserPayload {
email: string;
name: string;
age: number;
}
At runtime, parsed JSON is not validated by the interface.
The same principle applies to Python TypedDict.
Both ecosystems need runtime schemas or explicit parsers at trust boundaries.
Python differences:
TypedDictruntime values are ordinary dictionaries;- missing and null use
NotRequiredandNone; ReadOnlyis static only;- dictionary mutability affects compatibility;
- annotations can be inspected by frameworks.
26. Common mistakes
Casting parsed JSON
No validation occurs.
Using TypedDict as a domain entity
It cannot protect invariants well.
Confusing optional key with nullable value
They are distinct.
ReadOnly as runtime security
It is a static contract.
asdict for public responses
It may leak internals.
Ignoring unknown-field policy
Clients receive inconsistent behavior.
One giant parser
Split nested parsing.
Typed input but untyped output
Serialize responses explicitly too.
27. English vocabulary
| Term | Meaning |
|---|---|
| typed dictionary | static description of dictionary keys and values |
| required key | key expected to be present |
| optional key | key allowed to be absent |
| nullable | value allowed to be None |
| boundary | point where external data enters or leaves |
| parsing | converting raw input into structured values |
| validation | checking runtime data against rules |
| transport model | data shape used across a system boundary |
| unknown field | key outside the declared contract |
| serialization | converting internal values to external form |
Useful sentences:
- “The typed dictionary describes shape but performs no runtime validation.”
- “An optional key is not the same as a nullable value.”
- “The parser converts weak external data into a strong domain command.”
- “Casting the JSON only suppresses static uncertainty.”
- “The response serializer controls the public contract.”
- “The endpoint must document its unknown-field policy.”
28. Speaking task
Explain for twelve minutes:
Why a
TypedDictdoes not make JSON safe.
Include required keys, optional keys, ReadOnly, parsing, domain objects, and response serialization.
29. Writing task
Write a 500-word architecture proposal for an API that currently passes raw request dictionaries through controllers, services, repositories, and event publishers.
30. Exercises
Exercise 1
Create a typed dictionary for a user creation payload with an optional nickname.
Exercise 2
Create a PATCH typed dictionary distinguishing missing nickname from explicit None.
Exercise 3
Create a generic API response typed dictionary.
Exercise 4
Type reusable email keyword options with Unpack.
Exercise 5
Write a runtime parser converting raw user data into a dataclass command.
Exercise 6
Write an explicit response serializer excluding internal audit fields.
31. Complete solutions
Solution 1
from typing import (
NotRequired,
TypedDict,
)
class CreateUserPayload(
TypedDict
):
email: str
name: str
nickname: NotRequired[str]
Solution 2
class UserPatch(
TypedDict,
total=False,
):
nickname: str | None
Absence means unchanged; present None means clear.
The runtime parser must preserve this distinction.
Solution 3
class ApiResponse[T](
TypedDict
):
data: T
request_id: str
Solution 4
from typing import Unpack
class EmailOptions(
TypedDict,
total=False,
):
subject: str
urgent: bool
reply_to: str
def send_email(
recipient: str,
**options: Unpack[
EmailOptions
],
) -> None:
...
Solution 5
from dataclasses import dataclass
@dataclass(frozen=True)
class CreateUserCommand:
email: str
name: str
nickname: str | None
def parse_create_user(
value: object,
) -> CreateUserCommand:
if not isinstance(
value,
dict,
):
raise ValueError(
"payload must be an object"
)
email = value.get(
"email"
)
name = value.get(
"name"
)
nickname = value.get(
"nickname"
)
if not isinstance(
email,
str,
):
raise ValueError(
"email must be a string"
)
if not isinstance(
name,
str,
):
raise ValueError(
"name must be a string"
)
if (
nickname is not None
and not isinstance(
nickname,
str,
)
):
raise ValueError(
"nickname must be "
"a string or null"
)
normalized_email = (
email.strip().lower()
)
normalized_name = (
name.strip()
)
if (
not normalized_email
or "@"
not in normalized_email
):
raise ValueError(
"invalid email"
)
if not normalized_name:
raise ValueError(
"name must not be blank"
)
normalized_nickname = (
None
if nickname is None
else nickname.strip()
)
return CreateUserCommand(
email=normalized_email,
name=normalized_name,
nickname=(
normalized_nickname
),
)
A production parser should distinguish missing nickname from explicit null if update semantics require it.
Solution 6
class UserResponse(
TypedDict
):
id: int
email: str
display_name: str
def serialize_user(
user: User,
) -> UserResponse:
return {
"id": user.user_id,
"email": str(
user.email
),
"display_name": (
user.display_name
),
}
Internal fields such as password hashes, audit notes, and security flags remain excluded.
32. Chapter checkpoint
You should now be able to explain:
TypedDictruntime behavior;- required keys;
- optional keys;
- nullable values;
RequiredandNotRequired;ReadOnly;- generic typed dictionaries;
- typed
**kwargs; - TypedDict versus dataclass;
- validated boundaries;
- unknown-field policies;
- PATCH semantics;
- nested parsing;
- explicit response serialization.