Schemas from dataclasses
If your data already has a dataclass, you should not have to write the schema
twice. DataclassSchema reads a dataclass’s fields and builds a schema from the
annotations: it validates a plain mapping and hands you back a constructed
instance. This is the same idea as voluptuous PR #533, with a richer type
mapping (Probatio descends into element types instead of stopping at the
container).
The same engine reads a TypedDict. If your shape is a dict rather than a
constructed object, see Schemas from TypedDicts; this page is
about dataclasses.
The basics
Section titled “The basics”Pass a dataclass type to DataclassSchema. A field without a default is
required; a field with a default (or default_factory) is optional, and the
default fills in when the key is absent.
from dataclasses import dataclass
from probatio import DataclassSchema
@dataclassclass User: name: str age: int = 18
schema = DataclassSchema(User)schema({"name": "ada"}) # User(name='ada', age=18)The result is a real User, not a dict. A missing required field is reported
like any other validation error:
from dataclasses import dataclass
from probatio import DataclassSchema
@dataclassclass User: name: str age: int = 18
DataclassSchema(User)({}) # required key not provided at 'name'Annotations drive the validators
Section titled “Annotations drive the validators”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 dataclass
recurses into its own schema.
from dataclasses import dataclass, field
from probatio import DataclassSchema
@dataclassclass Address: street: str number: int = 0
@dataclassclass Person: name: str tags: list[str] = field(default_factory=list) home: Address | None = None
schema = DataclassSchema(Person)schema({"name": "ada", "tags": ["x"], "home": {"street": "Main", "number": 5}})# Person(name='ada', tags=['x'], home=Address(street='Main', number=5))The full set of mappings:
| Annotation | Schema |
|---|---|
int, str, a plain type | the type (an isinstance check) |
float | the numeric tower (int in, float out) |
list[T] | [T] (element type validated) |
set[T], frozenset[T] | {T} / frozenset([T]) |
dict[K, V] | {K: V} (key and value validated) |
tuple[X, Y] | ExactSequence([X, Y]) (positional) |
tuple[X, ...] | a homogeneous sequence of X |
X | None | Maybe(X) |
X | Y | Any(X, Y) |
Literal["a", "b"] | In(["a", "b"]) |
| a nested dataclass | its own generated schema |
Any | accepts any value |
A tuple field accepts a list or a tuple, since a sequence arrives as a list from JSON, and keeps whichever you gave it.
A float field is the one plain type that is not a bare isinstance. It honors
the PEP 484 numeric tower: an int is a valid float value, so 5 is accepted
and normalized to 5.0, while bool, a string, and None are still rejected. See
ADR-017.
Layering extra rules
Section titled “Layering extra rules”The annotation gives you the type check. For anything beyond that (a length, a
range, a regular expression), pass additional_constraints: a map from field
name to a validator that runs after the type check.
from dataclasses import dataclass
from probatio import DataclassSchema, Length
@dataclassclass User: name: str
schema = DataclassSchema(User, {"name": Length(min=2)})schema({"name": "ada"}) # User(name='ada')The same rule can live on the field itself with Annotated, keeping the
constraint next to the field instead of in a separate map. The rules:
- The first argument is the type; every callable after it is applied as a validator, in order.
- The type is checked on the result, not the raw input, so the annotation says what the field is: a coercer runs first and the type confirms what it produced.
- It composes inside containers:
list[Annotated[int, Range(min=1)]]checks every element. - A producing base (an enum, a nested dataclass, a container) coerces first, so
an asserting constraint after it checks the produced value. A coercer in the
metadata (
CoerceorMap) still runs ahead of it, so you can reshape a raw value, like folding an enum sentinel toNone, before the base sees it (see the cookbook). - Metadata that is not callable is left alone, so an
Annotatedvalue you share with another tool passes through untouched.
That second rule is what keeps a coerced field honestly typed.
Annotated[datetime, AsDatetime()] accepts the string, parses it, and confirms
a datetime, instead of annotating str and hiding the real type in the
validator. To find the right callable to drop in here, whether it checks the
value or transforms it, see Built-ins by role.
from dataclasses import dataclassfrom typing import Annotated
from probatio import DataclassSchema, Length, Range
@dataclassclass User: name: Annotated[str, Length(min=2)] age: Annotated[int, Range(min=0)] = 0
schema = DataclassSchema(User)schema({"name": "ada", "age": 30}) # User(name='ada', age=30)Here is that coercer-plus-type pairing in practice:
from dataclasses import dataclassfrom datetime import datetimefrom typing import Annotated
from probatio import DataclassSchema, AsDatetime
@dataclassclass Event: when: Annotated[datetime, AsDatetime()]
DataclassSchema(Event)({"when": "2020-01-01T12:00"})# Event(when=datetime.datetime(2020, 1, 1, 12, 0))A NewType is followed to the type it wraps, so a field typed
UserId = NewType("UserId", int) validates as an int.
Key facets on fields
Section titled “Key facets on fields”A plain type says what a field is. To give a field a key facet, redact it,
accept it under other names, forbid it, group it, add Key to its Annotated
metadata. It sits next to any value validators; the two do not interfere.
from dataclasses import dataclassfrom typing import Annotated
from probatio import DataclassSchema, Key, Length
@dataclassclass Account: name: str password: Annotated[ str, Key(secret=True), Length(min=8) ] # redacted, length-checked user_name: Annotated[str, Key(alias=["user-name", "userName"])] = ( "" # accept aliases ) is_admin: Annotated[bool, Key(forbidden=True)] = ( False # reject if the caller sends it )
schema = DataclassSchema(Account)schema({"name": "a", "password": "secret123", "user-name": "frenck"})# Account(name='a', password='secret123', user_name='frenck', is_admin=False)Key(secret=True) redacts the field’s value in validation errors. It shows up
when an error renders the offending value: the message stays useful, the secret
does not leak.
from probatio import MultipleInvalidfrom probatio.humanize import humanize_error
data = {"name": "a", "password": "short", "user-name": "frenck"}try: schema(data)except MultipleInvalid as err: print(humanize_error(data, err))# length of value must be at least 8 at 'password'. Got <redacted>Key(alias=[...]) accepts the field under alternate input names (a bare string
works for one), emitting it under the field name; accept_canonical=False makes it
a strict rename. Key(inclusive="grp") / Key(exclusive="grp") group fields the
way the dict form does. Key(required=True)
(or required=False) overrides the presence the field’s default would imply.
required=False marks a field optional, so on a dataclass it still needs a
default for the constructor to fall back on; without one it raises SchemaError.
Key is a field-only spec; a plain dict schema keeps using the markers directly
({Secret("password"): str}, {Alias("user_name", "user-name"): str}). It works
the same on a TypedDict.
forbidden and remove make a key contribute nothing to the result, so on a
dataclass (whose constructor needs a value for every field) such a field must have
a default; without one it raises SchemaError. A TypedDict constructs nothing, so
there they need no default.
One boundary to know, on which defaults pass through the schema:
- An
optionalfield, or a selectedexclusivemember, carries its default through the schema, so it is validated and coerced like input. - A field the schema keeps out of the input (a
forbiddenfield, aremovefield, an unselectedexclusivemember) takes the dataclass’s own default exactly as declared: no validation, no coercion.
A nested dataclass field can default to a built instance, the idiomatic
“always-present, defensive empty object”: inner: Inner = field(default_factory=Inner).
When the key is absent, the Inner() fills in as-is; it is not re-validated as a
mapping. A caller may also hand in an already-built instance for such a field, and
it passes through untouched; only a mapping is validated and constructed.
The schema validates input, and that second group’s values are not input, so write an already-typed default for such a field; a wrong-typed one is a type error your type-checker flags. See ADR-013 for the model.
The functional form and the helper
Section titled “The functional form and the helper”create_dataclass_schema(dataclass_type, additional_constraints=None) builds the
same schema without the class wrapper, and is_dataclass is the standard-library
check, re-exported so you do not have to import it separately.
Both DataclassSchema and create_dataclass_schema also take keyword-only
required and extra arguments, passed straight through to Schema, so you can,
for example, accept and keep unknown keys with extra=ALLOW_EXTRA.
from dataclasses import dataclass
from probatio import create_dataclass_schema, is_dataclass
@dataclassclass User: name: str
is_dataclass(User) # Truecreate_dataclass_schema(User)({"name": "ada"}) # User(name='ada')A from_dict mixin
Section titled “A from_dict mixin”Parsing an API payload into a dataclass tree is the common shape: a
DataclassSchema built once, then called, usually behind a from_dict
classmethod. SchemaMixin bundles that into the class. Inherit it, pass the
extra-key policy as a class argument, and the dataclass gets a cached, validating
from_dict, with no separate module-level schema and no hand-written classmethod.
from dataclasses import dataclass
from probatio import REMOVE_EXTRA, SchemaMixin
@dataclassclass Server(SchemaMixin, extra=REMOVE_EXTRA): host: str = "" port: int = 80
Server.from_dict({"host": "nas", "port": 8080, "unmodeled": 1})# Server(host='nas', port=8080)from_dict returns an instance of the class, typed as such, so your editor and
type-checker know the result. The DataclassSchema is built on the first call and
reused after, and extra is inherited: a subclass that sets none keeps the parent’s
policy. The class stays an ordinary dataclass; the mixin adds from_dict and records
extra, and leaves the fields alone. A TypedDict cannot carry methods, so it keeps
using TypedDictSchema directly.
from_dict is the validation boundary, not the class. Coming from pydantic, this
is the one thing to know: SchemaMixin does not make the dataclass validate itself.
Constructing one directly (Server(host="nas", port="no")) runs no validation, the
same as any plain dataclass, and a type-checker already flags the wrong type there.
Validate untrusted input at the edge with from_dict, and treat an instance you
already hold as trusted. The schema stays separate from the data, so the same
dataclass keeps working with probatio nowhere in sight.
Extra keys, all the way down
Section titled “Extra keys, all the way down”extra propagates. Set extra=REMOVE_EXTRA (or ALLOW_EXTRA) and the policy
applies at every level: nested dataclasses, nested TypedDicts, and dataclasses
inside a list, dict, tuple, or union all follow it. This is the case for
parsing a third-party API response into a tree of dataclasses while dropping the
fields you do not model, one flag, not a wrapper per level.
from dataclasses import dataclass, field
from probatio import DataclassSchema, REMOVE_EXTRA
@dataclassclass Server: host: str = ""
@dataclassclass Config: server: Server = field(default_factory=Server)
data = {"server": {"host": "nas", "unmodeled": 9}, "toplevel_junk": 1}DataclassSchema(Config, extra=REMOVE_EXTRA)(data) # Config(server=Server(host='nas'))To pin a different policy for one field’s subtree, put Key(extra=...) in its
Annotated metadata. It overrides the inherited policy for that field only, so
you can keep one noisy sub-object loose while the rest stays strict, or the
reverse:
from dataclasses import dataclass, fieldfrom typing import Annotated
from probatio import DataclassSchema, Key, PREVENT_EXTRA
@dataclassclass Strict: id: int = 0
@dataclassclass Doc: meta: Annotated[Strict, Key(extra=PREVENT_EXTRA)] = field(default_factory=Strict)The schema is loose, but meta stays strict and rejects the unknown key:
DataclassSchema(Doc, extra=REMOVE_EXTRA)({"meta": {"id": 1, "typo": 2}})# MultipleInvalid: not a valid option at 'meta.typo'The default (PREVENT_EXTRA) is unchanged: an unknown key, at any level, is
rejected. Key(extra=...) on a plain (leaf) field has nothing to apply to and is
ignored.
Coercing a field’s type
Section titled “Coercing a field’s type”A field annotated with datetime validates by isinstance, so a string from
JSON or YAML is rejected. That is the safe default: probatio validates, it does
not silently transform. When you do want a field coerced, say so on the field with
an Annotated hint, and the coercion is scoped to exactly that field:
from dataclasses import dataclassfrom datetime import datetimefrom typing import Annotated
from probatio import DataclassSchema, Coerce
@dataclassclass Event: when: Annotated[datetime, Coerce(datetime.fromisoformat)]
DataclassSchema(Event)( {"when": "2020-01-01T12:00"}) # Event(when=datetime(2020, 1, 1, 12, 0))The Coerce runs first and the datetime type confirms the result, so the field
stays honestly typed (see the annotation mapping
table). This works the same way in a nested
dataclass field, and additional_constraints still layers on top. The
hand-written Schema(datetime) path is never affected.
It holds for a nullable or union field too: the coercer runs before the
X | None (or X | Y) base confirms the result, so Annotated[int | None, Coerce(...)]
coerces a raw string and Annotated[datetime | None, Coerce(FromEpoch())] turns a
timestamp into a datetime, keeping the field’s real int | None /
datetime | None type instead of falling back to Any.
Reach for a built-in coercer before writing a lambda. AsTimedelta reads a
count of seconds, a H:MM:SS string, or an ISO 8601 duration into a timedelta,
and Maybe(Coerce(float)) coerces a value while letting None through. Both
compose straight onto a field, so a “seconds or a timedelta” field is
Annotated[timedelta, AsTimedelta()] and an “optional, coerce if present” field
is Annotated[float | None, Maybe(Coerce(float))], with no coercer of your own to
write or test:
from dataclasses import dataclassfrom datetime import timedeltafrom typing import Annotated
from probatio import AsTimedelta, Coerce, DataclassSchema, Maybe
@dataclassclass Job: timeout: Annotated[timedelta, AsTimedelta()] = timedelta() ratio: Annotated[float | None, Maybe(Coerce(float))] = None
DataclassSchema(Job)({"timeout": 90, "ratio": "0.5"})# Job(timeout=datetime.timedelta(seconds=90), ratio=0.5)The As* coercers return their native type unchanged (AsTimedelta on a
timedelta, AsDatetime on a datetime), so they are idempotent in the sense
above: one sits on a defaulted field without re-converting the default when it
flows back through the schema.
Recursive dataclasses
Section titled “Recursive dataclasses”A dataclass whose field refers back to itself (a tree node, a linked list) is supported: the field validates against the same schema, all the way down, and each level is constructed. Mutually recursive dataclasses (two types that point at each other) work the same way.
from __future__ import annotations
from dataclasses import dataclass, field
from probatio import DataclassSchema
@dataclassclass Tree: name: str children: list[Tree] = field(default_factory=list)
DataclassSchema(Tree)({"name": "root", "children": [{"name": "leaf"}]})# Tree(name='root', children=[Tree(name='leaf', children=[])])Recursion follows the data, with the same depth guard as Self: cyclic or
pathologically deep input raises a clean Invalid, never a RecursionError. A
recursive dataclass level does more work than a bare Self level, so it bottoms
out sooner; raise the limit with sys.setrecursionlimit() if you genuinely need
to go deeper.
Discriminated unions
Section titled “Discriminated unions”A field that is a union of dataclasses sharing a literal tag field becomes a discriminated union: the tag picks the one branch to validate, rather than trying each member in order. The branch is chosen by the tag, so a failure reports that branch’s error instead of a vague “matched no member”.
from __future__ import annotations
from dataclasses import dataclassfrom typing import Literal
from probatio import DataclassSchema
@dataclassclass Circle: kind: Literal["circle"] radius: int
@dataclassclass Square: kind: Literal["square"] side: int
@dataclassclass Shape: shape: Circle | Square
DataclassSchema(Shape)({"shape": {"kind": "square", "side": 3}})# Shape(shape=Square(kind='square', side=3))The tag field must be a single-value Literal present on every member, with a
distinct value per member. Without one (or if a member is not a dataclass), the
union stays an ordinary “try each” Any. An unknown tag value falls back to
trying every member, so it still fails cleanly rather than silently.
A DataclassSchema is the clearest case for the compiled engine. When a schema
compiles, it fuses field validation and object construction into a single generated
function with no intermediate dict, which is most of why a hot dataclass schema
validates several times faster than the interpreted one and lands close to a pure
deserializer like mashumaro while still checking every field. This happens on its
own once the schema proves hot; see Compiled schemas
to opt in eagerly or read the trade-offs.
Trusted construction, without validation
Section titled “Trusted construction, without validation”Probatio is a validation library, but there are spots where validation is not
needed: the input is your own data round-tripping back in, or it was already
validated upstream. For those, DataclassSchema.construct builds the instance from
trusted data and skips validation entirely.
from dataclasses import dataclass
from probatio import DataclassSchema
@dataclassclass Point: x: int y: int
@dataclassclass Line: start: Point end: Point
schema = DataclassSchema(Line)# Trusted input: build straight through, no type checks, no coercion.schema.construct({"start": {"x": 0, "y": 0}, "end": {"x": 3, "y": 4}})# Line(start=Point(x=0, y=0), end=Point(x=3, y=4))It reads each field straight from the dict, recursing into nested dataclasses, lists
of them, Optional fields, and a single dataclass inside a union (like
Comment | str), and filling defaults, then constructs. With no checks it is faster
than validating, fast enough to beat dedicated deserializers like mashumaro on the
performance page, because it is a purpose-built
constructor and pure Python.
The catch is in the name: it trusts you. A wrong type lands in the instance unchecked,
and it does not convert: a field typed datetime will hold whatever the dict held
(a string stays a string), where validation would coerce it. For input that still
needs decoding, validate.
Limits
Section titled “Limits”A field with init=False is left out of the schema, since it is not a constructor
argument; it keeps whatever its default or __post_init__ gives it. An InitVar
goes the other way: it is a constructor argument, so it becomes a schema key,
validated against its annotation like any field, and it feeds __post_init__ as
usual.
from dataclasses import InitVar, dataclass, field
from probatio import DataclassSchema
@dataclassclass Rect: width: int height: int scale: InitVar[int] = 1 area: int = field(init=False, default=0)
def __post_init__(self, scale: int) -> None: self.area = self.width * self.height * scale
DataclassSchema(Rect)({"width": 2, "height": 3, "scale": 10})# Rect(width=2, height=3, area=60)The value is not coerced between container types: a list stays a list even where
the annotation says tuple.