Skip to content

Schemas from TypedDicts

A TypedDict describes the shape of a plain dict: which keys it has and what each value is typed as. TypedDictSchema reads that shape and builds a schema from it, so you validate against the keys you already declared instead of writing the schema twice.

The annotation engine is shared with dataclasses, but the two are different tools for different jobs. A dataclass schema validates a mapping and hands you back a constructed instance. A TypedDict is a plain dict at runtime, so a TypedDict schema validates the mapping and returns it unchanged, just typed. Dict in, dict out, at no construction cost.

Pass a TypedDict type to TypedDictSchema. The validated mapping comes back as is, and your type checker sees it as the TypedDict, so result["port"] is an int to the type checker with nothing extra at runtime.

from typing import TypedDict
from probatio import TypedDictSchema
class Config(TypedDict):
port: int
host: str
schema = TypedDictSchema(Config)
schema({"port": 8080, "host": "nas"}) # {'port': 8080, 'host': 'nas'}

By default every key is required, and a missing one is reported like any other validation error:

from typing import TypedDict
from probatio import TypedDictSchema
class Config(TypedDict):
port: int
host: str
TypedDictSchema(Config)({"port": 8080}) # required key not provided @ data['host']

The keys are closed: an unknown key is rejected, the same as the default dict schema. Pass extra=ALLOW_EXTRA (keyword-only) to keep unknown keys instead.

A TypedDict carries its own notion of which keys are required, and the schema honors it. A total=False class makes every key optional; Required and NotRequired set it per key. An optional key that is absent is simply left out of the result, no default is invented.

from typing import TypedDict, NotRequired
from probatio import TypedDictSchema
class Server(TypedDict):
name: str
port: NotRequired[int]
schema = TypedDictSchema(Server)
schema({"name": "nas", "port": 22}) # {'name': 'nas', 'port': 22}
schema({"name": "nas"}) # {'name': 'nas'}

A total=False class flips the default, so nothing is required:

from typing import TypedDict
from probatio import TypedDictSchema
class Partial(TypedDict, total=False):
a: int
b: str
TypedDictSchema(Partial)({}) # {}

Each field’s annotation becomes a validator. The mapping is deep, not just the container type: a parameterized generic keeps its element types, a union with None becomes Maybe, and a nested TypedDict (or dataclass) recurses into its own schema. The full table is the same one dataclasses use.

from typing import TypedDict
from probatio import TypedDictSchema
class Config(TypedDict):
port: int
host: str
class Service(TypedDict):
name: str
config: Config
TypedDictSchema(Service)({"name": "web", "config": {"port": 80, "host": "nas"}})
# {'name': 'web', 'config': {'port': 80, 'host': 'nas'}}

For anything beyond the type check (a length, a range, a pattern), the same two options as dataclasses apply: pass additional_constraints, a map from key to a validator that runs after the type check, or put the rule on the field with Annotated.

from typing import TypedDict
from probatio import TypedDictSchema, Length
class Config(TypedDict):
port: int
host: str
schema = TypedDictSchema(Config, {"host": Length(min=2)})
schema({"port": 8080, "host": "nas"}) # {'port': 8080, 'host': 'nas'}

create_typeddict_schema(typeddict_type, additional_constraints=None) builds the same schema without the class wrapper. It returns a plain Schema, so it does not carry the TypedDict type for the type checker the way TypedDictSchema does.

from typing import TypedDict
from probatio import create_typeddict_schema
class Config(TypedDict):
port: int
host: str
create_typeddict_schema(Config)({"port": 8080, "host": "nas"})
# {'port': 8080, 'host': 'nas'}

The two share the annotation engine, so the dataclasses guide covers the rest, and it all behaves the same with a TypedDict:

The one difference is the result: a dataclass schema constructs an instance, a TypedDict schema returns the validated dict.

The value is not coerced between container types: a list stays a list even where the annotation says tuple.