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"}
user = 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_ids = 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
| Kind | Schemas |
|---|---|
| Primitives | S.String, S.Int, S.Float, S.Bool, S.Null, S.Unknown, S.Literal(...), S.instance_of(cls) |
| Collections | S.Array(item), S.Record(key, value), S.Tuple(...), S.Union(...), S.NullOr(schema) |
| Transforms | S.IntFromString, S.FloatFromString, S.DateTimeFromString, S.DateFromString, S.PathFromString |
| Refinements | S.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.
| pydantic | E.Schema | |
|---|---|---|
| Validation result | Raises ValidationError | Returns Effect[A, ParseError]; nothing runs until the effect runs, and the error travels in the typed E channel |
| Coercion | Lax by default: "36" becomes 36, 1 becomes True | Strict only: S.Int rejects "36", 36.0 and True |
| Encoding | model_dump() and model_dump_json(), with serializers configured separately from validators | Every schema is a codec pair, so encode is derived from the same definition and round-trips by construction |
| Wire conversions | Built in for datetime, UUID, Path, enums and more | Explicit: created: datetime is a TypeError; write S.field(S.DateTimeFromString) |
| Models | BaseModel, mutable by default, configured through model_config | S.Struct: a frozen, keyword-only dataclass with no config object |
| Containers | list[T], dict[str, T], set[T] | tuple[T, ...] and Mapping[str, T]; arrays decode to tuples |
| Constraints | Field(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_serializer | S.transform and S.transform_or_fail, each with a decode= and an encode= function |
| Ad-hoc schemas | TypeAdapter(list[int]) | Combinators are values: S.Array(S.Int), S.Union(...), S.Record(...) |
| Unknown keys | Ignored by default; can be forbidden or kept | Ignored |
| Aliases | Field(alias=...), validation_alias, serialization_alias | One key=, used in both directions |
| Recursive models | Supported, including forward references | Not supported; nested structs are defined first |
| JSON Schema | model_json_schema() | Not available |
| Runtime | Rust core (pydantic-core) | Pure Python, no dependencies |
| Type checker | mypy and pyright through a plugin or dataclass_transform | Designed for ty: dataclass_transform types fields and __init__, and the decoded and encoded types are pinned |