Skip to content
Logo

Schema

E.Schema describes the shape of data once and works in both directions: decode turns untrusted input into typed values, encode turns typed values back into their wire form. Both return effects that fail with a single ParseError listing every issue found, each tagged with the path where it occurred.

class (S.):
    : 
    :  = S.(
        S..(S.(lambda :  >= 0, message="expected a non-negative age"))
    )
    :  = S.(S., key="createdAt")
    : [, ...] = ()
 
 
 = {"name": "Ada", "age": 36, "createdAt": "2026-09-20T10:30:00"}
userArrow
= S.()()

Structs

A Struct subclass is a frozen, keyword-only dataclass, so the decoded value is a plain typed object. Annotations are the decoded types. A schema is inferred for str, int, float, bool, None, unions of those, Literal[...], tuple[T, ...], Mapping[str, T] and nested structs; anything else names its schema through S.field(schema), or the class definition raises TypeError. key= renames the field on the wire, and a field with a default may be absent from the input; a default its own schema rejects is a TypeError at class definition, as are two fields sharing a wire key. Unknown input keys are ignored. A nested struct must be defined before the struct that references it: forward and self references are not supported. Issue paths use wire keys when decoding and field names when encoding.

Two directions

Every schema is a Schema[A, I]: A is the decoded type and I the encoded one. S.IntFromString is a Schema[int, str], so it decodes "42" to 42 and encodes 42 back to "42".

user_idsArrow
= S.(S.)
= S.()(["1", "2"]) # succeeds with (1, 2) = S.()((1, 2)) # succeeds with ["1", "2"]

Build your own with S.transform, or with S.transform_or_fail when a direction can reject its input by returning S.Invalid(message). An exception raised inside a transform is a defect, not a ParseError. Pass to= a schema for the decoded type — usually S.instance_of(cls) or a primitive — and encode checks the value against it before calling your function, while decode checks your function's result. Without to, encode hands the value straight to the function, so inside a S.Union or S.NullOr your function can receive a value meant for another member; every built-in transform is guarded this way.

def (: ) ->  | S.:
    return  if 0 <  < 65536 else S.("expected a port number")
 
 
 = S.(S., decode=, encode=)

Building blocks

KindSchemas
PrimitivesS.String, S.Int, S.Float, S.Bool, S.Null, S.Unknown, S.Literal(...), S.instance_of(cls)
CollectionsS.Array(item), S.Record(key, value), S.Tuple(...), S.Union(...), S.NullOr(schema)
TransformsS.IntFromString, S.FloatFromString, S.DateTimeFromString, S.DateFromString, S.PathFromString
RefinementsS.filter(predicate, message=...), S.pattern(regex)

Primitives are strict and never coerce: S.Int rejects True and 1.0. Arrays decode to tuples. Refinements are Check values: S.filter wraps any predicate with its failure message (S.filter(lambda n: n > 0, message="expected a positive number")) and S.pattern matches a regular expression. They are attached with schema.check(...), and guard both directions, so encoding an invalid value fails too. schema.check(c1, c2) reports every check that fails, not just the first. S.Array and S.NullOr take a Struct class directly; elsewhere use S.struct_schema(User). S.Literal("a", "b") keeps its literal member types only where an expected type guides inference, such as an annotated variable or a Literal[...]-typed struct field; called bare, it widens to Schema[str, str].

Errors

A failed decode or encode fails with S.ParseError. Its issues tuple holds every problem in the input — TypeMismatch, MissingKey, RefinementFailed, TransformFailed, NoUnionMember or InvalidJson — and str(error) renders one line per issue:

name: expected string, got 1
address.zip: is missing
tags[1]: expected string, got 2

JSON

S.decode_json(schema)(text) parses JSON text before decoding, reporting malformed text as an InvalidJson issue, and S.encode_json(schema)(value) serializes after encoding. Decoding also composes with anything else that yields raw data, such as an HTTP response body:

@E.
def (
    : ,
) -> E.[
    ,
    E.HttpClient. | E.HttpClient. | S.,
    E.HttpClient.,
]:
     = yield from E.(E.HttpClient.)
 
     = yield from .()
     = yield from .()
    return (yield from S.()())

Compared to pydantic

E.Schema covers the same ground as pydantic's models and TypeAdapter, but makes different choices where the effect system or strictness calls for them.

pydanticE.Schema
Validation resultRaises ValidationErrorReturns Effect[A, ParseError]; nothing runs until the effect runs, and the error travels in the typed E channel
CoercionLax by default: "36" becomes 36, 1 becomes TrueStrict only: S.Int rejects "36", 36.0 and True
Encodingmodel_dump() and model_dump_json(), with serializers configured separately from validatorsEvery schema is a codec pair, so encode is derived from the same definition and round-trips by construction
Wire conversionsBuilt in for datetime, UUID, Path, enums and moreExplicit: created: datetime is a TypeError; write S.field(S.DateTimeFromString)
ModelsBaseModel, mutable by default, configured through model_configS.Struct: a frozen, keyword-only dataclass with no config object
Containerslist[T], dict[str, T], set[T]tuple[T, ...] and Mapping[str, T]; arrays decode to tuples
ConstraintsField(gt=0, min_length=2) or Annotated[int, Gt(0)]schema.check(S.filter(lambda n: n > 0, message=...)) and S.pattern
Custom logic@field_validator, @model_validator, @field_serializerS.transform and S.transform_or_fail, each with a decode= and an encode= function
Ad-hoc schemasTypeAdapter(list[int])Combinators are values: S.Array(S.Int), S.Union(...), S.Record(...)
Unknown keysIgnored by default; can be forbidden or keptIgnored
AliasesField(alias=...), validation_alias, serialization_aliasOne key=, used in both directions
Recursive modelsSupported, including forward referencesNot supported; nested structs are defined first
JSON Schemamodel_json_schema()Not available
RuntimeRust core (pydantic-core)Pure Python, no dependencies
Type checkermypy and pyright through a plugin or dataclass_transformDesigned for ty: dataclass_transform types fields and __init__, and the decoded and encoded types are pinned