Batu Lab NotesPractical developer guides

Check a mapping shape before treating it as TypedDict-like

By Batu ยท English technical notes

Also published in our Blogger archive.

TypedDict makes a mapping shape visible to type checkers, but it does not validate runtime input. At runtime, a TypedDict instance is an ordinary dict, so a boundary function must perform the checks its application needs. parse_job() first accepts only collections.abc.Mapping objects, then requires exactly the name and attempt keys. It obtains those values only after the shape check and returns a normal dictionary matching the Job annotation.

For the accepted input, the result is {"name": "compile", "attempt": 2}. A missing key is rejected as an unexpected shape. A boolean attempt is rejected because this contract uses type(attempt) is int; that deliberately excludes True, even though bool subclasses int. Choose isinstance instead if subclasses are valid for your own format.

Exact-key validation intentionally rejects extra fields. That is appropriate for a closed record but not for an extensible payload, where the check should instead allow documented optional or future keys. These checks do not sanitize values, prove a mapping is trustworthy, or replace authorization decisions.

TypedDict was added in Python 3.8. The typing documentation explains its static-only expectations, and the collections.abc documentation defines the Mapping interface used for the first runtime check.

AI-assistance disclosure: this article was drafted with AI assistance and should be adapted to the payload schema it protects.

from collections.abc import Mapping
from typing import TypedDict


class Job(TypedDict):
    name: str
    attempt: int


def parse_job(candidate: object) -> Job:
    if not isinstance(candidate, Mapping):
        raise ValueError("job must be a mapping")
    if set(candidate) != {"name", "attempt"}:
        raise ValueError("job has unexpected shape")

    name = candidate["name"]
    attempt = candidate["attempt"]
    if type(name) is not str or type(attempt) is not int:
        raise ValueError("job has invalid value types")

    return {"name": name, "attempt": attempt}


job = parse_job({"name": "compile", "attempt": 2})
assert job == {"name": "compile", "attempt": 2}

for candidate in ({"name": "compile"}, {"name": "compile", "attempt": True}):
    try:
        parse_job(candidate)
    except ValueError as error:
        print(error)

print(f"job={job['name']} attempt={job['attempt']}")
job has unexpected shape
job has invalid value types
job=compile attempt=2