Zum Hauptinhalt springen

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 **kwargs shape 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:

  • email may be missing;
  • nickname may be missing;
  • when present, nickname may be None.

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:

  • TypedDict runtime values are ordinary dictionaries;
  • missing and null use NotRequired and None;
  • ReadOnly is 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

TermMeaning
typed dictionarystatic description of dictionary keys and values
required keykey expected to be present
optional keykey allowed to be absent
nullablevalue allowed to be None
boundarypoint where external data enters or leaves
parsingconverting raw input into structured values
validationchecking runtime data against rules
transport modeldata shape used across a system boundary
unknown fieldkey outside the declared contract
serializationconverting 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 TypedDict does 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:

  1. TypedDict runtime behavior;
  2. required keys;
  3. optional keys;
  4. nullable values;
  5. Required and NotRequired;
  6. ReadOnly;
  7. generic typed dictionaries;
  8. typed **kwargs;
  9. TypedDict versus dataclass;
  10. validated boundaries;
  11. unknown-field policies;
  12. PATCH semantics;
  13. nested parsing;
  14. explicit response serialization.