Skip to content
You are reading v0.2, which is not the current release. Go to v0.4.

std::schema

One description of a value’s shape, used to decode untrusted input.

A schema describes a value. It does not know where the bytes came from. That separation is the whole of the library. A Schema<A> describes an A; a Raw is whatever a source produced – a JSON document, the environment, a query string, a database row; and decode is one function over the pair. The same Schema<Settings> reads a request body, a test fixture and the deployment’s variables, and does not know which.

A schema carries a read closure and a Shape beside it, and both are load-bearing. The closure is what makes schema.decode(value) an ordinary call and what lets schemas be combined. The shape is untyped – no type parameter – which is what lets a record’s fields sit in a List and be walked, so a deployment can ask which keys a configuration needs without starting the program, and a document can be rendered from the same value that checks the input.

Decoding answers a Validated, so a record with three bad fields reports three. A person fixing a deployment wants the list, not one line of it at a time. Validated::to_result is one call for a caller who wants to stop.

Strict where the source could say, lenient where it could not

Section titled “Strict where the source could say, lenient where it could not”

int() reads a Raw::Number, and refuses a Raw::Text holding "8080": a JSON body that sends a number as a string is wrong, and serde and encoding/json refuse it too. The environment cannot label anything, so it hands over Raw::Untyped, which every primitive reads. Leniency is a fact about the source, recorded by the source, and no primitive has to guess.

Types: Raw · Segment · Rule · Problem · Rejection · Shape · Named · Alternative · Schema · Fields · Case

Traits: Decode · Encode

Methods: Raw · Rule · Segment · Rejection · Shape · Schema<A> · Fields<A> · Shape

Trait implementations: Show for Shape · Decode for String · Decode for Int · Decode for Float · Decode for Bool · Decode for Decimal · Decode for Raw · Decode for Json · Decode for Date · Decode for Time · Decode for DateTime · Decode for Option<A> · Decode for List<A> · Decode for Vector<A> · Decode for Redacted<A> · Decode for Dict<String, V> · Decode for Map<String, V> · Encode for String · Encode for Int · Encode for Float · Encode for Bool · Encode for Decimal · Encode for Raw · Encode for Json · Encode for Date · Encode for Time · Encode for DateTime · Encode for Option<A> · Encode for List<A> · Encode for Vector<A> · Encode for Dict<String, V> · Encode for Map<String, V> · Encode for Rejection

Functions: string · int · float · decimal · bool · any · optional · nullable · default · list · dict · refine · via · between · at_least · at_most · min_length · max_length · min_items · max_items · non_empty · one_of · secret · key · renamed · struct · decode

derive(Show)
pub type Raw =
| Absent
| Null
| Text(text: String)
| Untyped(text: String)
| Number(text: String)
| Bool(value: Bool)
| Sequence(items: List<Raw>)
| Record(fields: List<Pair<String, Raw>>)
| Denied;

A value as a source handed it over, before anything has been asked of it.

A record’s fields are Pairs from std::core rather than a type of this module’s own, so anything that already walks a Pair walks these.

A number is its text, and that is not an oversight. A Float holds about fifteen significant digits, so 9007199254740993 comes back one short of itself and 10.10 is never exactly recoverable – and a schema that decoded a Decimal through one would rebuild, inside the library meant to prevent that class of thing, the exact bug it exists to prevent. Keeping the token lets int and decimal parse it exactly.

Null is not Absent. A key that was not there and a key that was there holding nothing are different facts to a client: optional treats both as nothing, and a required field given null says so rather than saying it was not set, which is the difference between a client bug and a deployment bug.

Untyped is text the source could not label. An environment variable, a command-line argument, a query parameter and a header are all text, and none of them knows whether 8080 was meant as a number. Every primitive reads an Untyped, and none reads a Text as anything but text, which is how one Schema<Int> refuses "port": "8080" in a JSON body and accepts PORT=8080 from the environment.

Denied is a value the source was not allowed to look at. The environment puts it where a variable the manifest does not grant would go, and it decodes as Problem::Denied rather than as missing, because the fix is a line in khora.toml and not in the deployment.

derive(Show, Eq)
pub type Segment = | Field(name: String) | Index(at: Int);

One step into a value, so an error can say listen.port or items[3].id.

derive(Show, Eq)
pub type Rule =
| Custom(must: String)
| Between(low: String, high: String)
| AtLeast(bound: String)
| AtMost(bound: String)
| MinLength(n: Int)
| MaxLength(n: Int)
| MinItems(n: Int)
| MaxItems(n: Int)
| OneOf(allowed: List<String>);

What a refinement asked of a value, kept as structure rather than as a sentence.

A sentence is what a person reads and a keyword is what a JSON Schema carries, and only structure can become both. refine takes a sentence and keeps it as Custom; the named rules – between, min_length and their neighbours – carry their bounds.

derive(Show)
pub type Problem =
| Missing
| Wrong(wanted: String, found: Redacted<String>)
| Refused(rule: Rule)
| Unexpected
| Denied;

What was wrong at one place.

found is a Redacted<String>, always, rather than only inside a secret. A decode error quotes what it saw, which is most of its value, and that is the easiest imaginable way to put a password in a log. Making the wrapper unconditional means Rejection::describe decides once whether to expose it and no future variant can forget.

Unexpected is a key a closed record did not declare, and only Schema::closed produces it: unknown keys are ignored by default, as every library this audience has used ignores them. Denied is a value the source was not allowed to read, kept apart from Missing because the fix is in khora.toml and not in the deployment.

derive(Show)
pub type Rejection = { path: List<Segment>, secret: Bool, problem: Problem };

A problem, and where it was.

The decoders build these, and a caller almost always only reads them – through Rejection::at and Rejection::describe. One is written by hand for a rule no combinator can hold, a cross-field check being the usual case: end is a valid timestamp and start is a valid timestamp and the pair of them is backwards, which nothing attached to either field can say. The record is public and has no constructor because there are three fields and nothing to check.

problem is what was wrong – Problem::Refused(Rule::Custom("after start")) for a rule of your own, where the text is the rest of the sentence after must be, as refine takes it. secret is whether the value came from somewhere that must not be quoted back, and only Problem::Wrong carries a value for it to suppress.

path is held innermost-first, which is the trap: Rejection::at reverses it on the way out, so a rejection about listen.port holds port first and a path written the way it reads comes out backwards. List::push puts a segment on the front, which is the order the decoders extend it in and the order to build one in.

// `window.end`, and note which push comes first.
let backwards: Rejection = {
path: List::push(List::push(List::Nil, Segment::Field("window")), Segment::Field("end")),
secret: false,
problem: Problem::Refused(Rule::Custom("after `start`")),
};
pub type Shape =
| Any
| String
| Int
| Float
| Decimal
| Bool
| List(of: Shape)
| Dict(of: Shape)
| Struct(fields: List<Named>)
| Cases(cases: List<Alternative>)
| Optional(inner: Shape)
| Nullable(inner: Shape)
| Default(inner: Shape)
| Keyed(wire: String, inner: Shape)
| Refined(inner: Shape, rule: Rule)
| Via(inner: Shape, becomes: String)
| Secret(inner: Shape)
| Closed(inner: Shape)
| Described(inner: Shape, text: String)
| Lazy(name: String, inner: () -> Shape);

The untyped half of a schema: what it describes, without saying as what.

No type parameter, which is the point. A record’s fields have different types and this is what lets them sit in one List and be walked – to list the keys a configuration needs, to print a request body in generated documentation, to render a JSON Schema. A closure answers none of those.

Every arm is named after the constructor that builds it, one for one: string builds String, list builds List, Schema::cases builds Cases, Schema::lazy builds Lazy. Struct is what Schema::record builds, and is named for the literal that will build it.

Lazy holds a thunk rather than a shape, so that a type which mentions itself has a shape at all; a walker forces it, and a walker that can meet the same name twice keeps a visited set. It is also why Show is written by hand below: nothing can be derived over a closure.

| Via(inner: Shape, becomes: String)

A value that arrives as one thing and becomes another: the wire shape, and the name of what it turns into. Via(String, "Timestamp") is a string on the wire and a Timestamp in the program.

The wire shape is the one that renders, because the producer has to send that and not the domain type; the name goes out as JSON Schema’s format, which is the keyword for exactly this.

derive(Show)
pub type Named = { name: String, shape: Shape };

One named field of a Shape::Struct.

name is the field’s name in the type. The key it is read under on the wire is the same unless the shape inside is Shape::Keyed, which Shape::keys looks through.

derive(Show)
pub type Alternative = { name: String, fields: List<Named> };

One case of a Shape::Cases: its tag, and the fields of its payload.

pub type Schema<A> = {
shape: Shape,
read: (List<Segment>, Raw) -> Validated<A, Rejection>,
};

A description of an A, and how to read one.

Built with the constructors below rather than by hand: the record is public so that a package may add a primitive of its own, and everything std offers goes through string, int and their neighbours.

pub type Fields<A> = {
fields: List<Named>,
read: (List<Segment>, Raw) -> Validated<A, Rejection>,
};

The fields of a record, decoding to an A.

What Schema::record turns into a schema. Two fields are joined with Fields::zip, which nests a tuple, and Fields::map turns the tuple into the record; there is no arity anywhere in it. Public because a package may want to build a record schema from something that is not source – a database catalog, say.

pub type Case<A> = {
name: String,
fields: List<Named>,
read: (List<Segment>, Raw) -> Validated<A, Rejection>,
};

One case of a variant: its tag on the wire, the fields of its payload, and how the payload becomes the value. Built by Schema::case.

pub trait Decode

A type that can be read from untrusted input.

The schema for a type is a property of the type, so it is reached through the type: Settings::schema() for one that implements this, and Decode::schema() where the expected type says which. std implements it for its own types; a program’s record or variant implements it with the constructors above.

fn schema() -> Schema<Self>

The schema for Self, chosen by the type the call is asked for.

pub trait Encode

A type with one representation on the wire.

Kept apart from Decode rather than folded into the schema, because a secret has no representation on the wire: Redacted implements Decode, through secret, and not this, so a record holding one reads and does not write, and the build says so rather than a round trip somewhere far away.

fn encode(self) -> Raw

The value as a source would have produced it; Raw::to_json is the bridge out.

impl Raw
pub fn entry(key: String, value: Raw) -> Pair<String, Raw>

One entry of a record: what a generated encoder writes a field as.

pub fn field(self, name: String) -> Raw

The field of a record, or Raw::Absent.

Absent rather than an Option, so that a missing field and a field holding nothing are the same thing to everything downstream and only optional has to know the difference.

pub fn describe(self) -> String

What to call this in a message: text, a number, and so on.

pub fn of_json(document: Json) -> Raw

A JSON document as a source produced it.

Total: null stays Null, a number keeps its literal, and an object becomes a record in the order Json::entries gives.

pub fn to_json(self) -> Json

The JSON a value renders to; the one bridge out.

An Absent entry is left out of an object and written as null inside an array, because an element cannot be omitted. Untyped is text, which is all a source that could not label it ever knew.

pub fn of_map(text: Map<String, String>) -> Raw

A record of text nobody labelled: a query string, the path parameters, the headers.

pub fn of_arguments(arguments: List<String>) -> Raw

Command-line arguments as a record.

--host x and --host=x are a field named host; a bare --verbose is true; -c is a field named c; - in a flag is _ in its name, so --log-level reaches a field called log_level; and anything that is not a flag is collected, in order, under arguments.

Shape-blind, so a flag followed by a word takes it as its value: -c .name reads c as .name. Raw::of_arguments_for knows which flags are switches, and is the one a program with a schema wants.

pub fn of_arguments_for(shape: Shape, arguments: List<String>) -> Raw

Command-line arguments as a record, read the way a shape says.

The grammar of Raw::of_arguments, except that a flag whose field is a Bool is a switch and never takes the word after it, so khq -c .name file.json reads c as true and .name as the first argument. The shape decides, because only the shape knows.

impl Rule
pub fn describe(self) -> String

The rule as the rest of a sentence beginning must: be between 1 and 65535, have at least 1 item.

impl Segment
pub fn write(self) -> String

The step as it appears in a path: .name or [3].

impl Rejection
pub fn at(self) -> String

Where this was, as a reader would write it: listen.port.

The leading dot is dropped, so a top-level field reads port rather than .port, and a failure at the root reads the value rather than nothing.

The path is held innermost-first and turned round here. Appending to the end of a List walks it, and a path is extended once per field at every depth; pushing is one cell and this runs only when something has gone wrong.

pub fn describe(self) -> String

One line a person can act on.

What was found is quoted, unless this is inside a secret. A message naming the bad value is what makes a decode error worth reading, and a message naming a password is a leak. The flag is set by secret and nothing else looks at it.

Text is written in double quotes and a number bare, so port should be a whole number, and is "8080" says what was wrong with it: 8080 is a whole number, and the problem was that it arrived as text.

pub fn describe_as(self, at: String) -> String

The same sentence, about at rather than the path as written: a source that spells a path its own way – LISTEN_PORT for the environment – supplies the subject and keeps the rest.

pub fn report(problems: List<Rejection>) -> String

Every problem, one per line, in the order they were found.

impl Shape
pub fn keys(self) -> List<String>

The keys a record shape needs, in the order they were declared, as they are spelled on the wire.

The question a deployment asks and a program should not have to be started to answer. Empty for anything that is not a record.

pub fn wire_key(self) -> Option<String>

The wire key a field shape was given by key, looking through the wrappers that may sit outside it.

impl<A> Schema<A>
pub fn decode(self, from: Raw) -> Validated<A, Rejection>

Reads a value, reporting every problem rather than the first.

The method a caller wants: Settings::schema().decode(input).

pub fn decode_or_stop(self, from: Raw) -> Result<A, List<Rejection>>

The same as a Result, for a caller that stops at the first problem.

For one field, or for a caller with nothing to do with a list.

It is the caller that stops, not the decode. A Validated invites the code around it to carry on and report everything; a Result is the shape ! carries straight out of the function. The Err still holds every rejection the decode found, because the combinators have no way to short-circuit: read is one function ending in a Validated, and Fields::zip reads both sides to collect both sides. Reading only as far as the first failure would mean a second reader on Schema, Fields and Case – which struct and derive(Decode) build from inside the compiler – so it is not a change this one function can make.

pub fn map<B>(self, f: (A) -> B) -> Schema<B>

The same shape, decoding to something made from the value.

How a newtype reads: int().map(fn n => UserId(n)).

pub fn try_map<B>(self, wanted: String, f: (A) -> Option<B>) -> Schema<B>

The same shape, decoding to something the value may fail to become.

Parse, don’t validate: string().try_map("an ISO 8601 date", Date::of_string) is a Schema<Date>, and a value that is not one reads when should be an ISO 8601 date, and is "yesterday".

pub fn record(fields: Fields<A>) -> Schema<A>

A record, from its fields.

The value has to be a record: a field that is not there is reported by the field, and something that is not a record at all is reported once.

pub fn case<F>(name: String, fields: Fields<F>, make: (F) -> A) -> Case<A>

One case of a variant, for Schema::cases.

make turns the decoded payload into the value; a case with no payload takes Fields::none and ignores its argument.

pub fn cases(cases: List<Case<A>>) -> Schema<A>

A variant, tagged with type.

A payload-free case is a bare string on the wire and a payload case is an object whose first key is type. "Local" and { "type": "Remote", "url": ".." } are the two forms, decided by the case and not by its siblings, so adding a payload case to a shipped enum does not change how the existing cases are written. That is what a TypeScript union of a literal and a tagged object encodes, and what JSON Schema’s oneOf expresses directly.

The tag key is type because type is a keyword, and a record field cannot be named by it, so no payload can ever collide with its own tag.

pub fn cases_tagged(tag: String, cases: List<Case<A>>) -> Schema<A>

A variant tagged with a key of a foreign API’s choosing: kind, _tag.

pub fn lazy(name: String, build: () -> Schema<A>) -> Schema<A>

A schema built when it is first read, so a type may mention itself.

name is what the shape carries, and what a rendered document uses to refer to the type a second time. Construction never recurses, because nothing inside is built until a value arrives; the cost is that the inner schema is rebuilt once per value decoded.

pub fn closed(self) -> Schema<A>

The same record, refusing a key it did not declare.

Unknown keys are ignored by default, which is what every library this audience has used does; a boundary that wants to reject a misspelled setting rather than ignore it asks for it here, and gets verbose is not expected for each one.

pub fn described(self, text: String) -> Schema<A>

The same schema, with a sentence for a document to carry.

impl<A> Fields<A>
pub fn of(name: String, inner: Schema<A>) -> Fields<A>

One field, read under its own name – or under the wire key its schema was given by key.

pub fn none() -> Fields<()>

No fields at all: the payload of a case that has none.

pub fn zip<B>(self, other: Fields<B>) -> Fields<(A, B)>

Both sets of fields, decoding to the pair, keeping both sides’ failures.

pub fn map<B>(self, f: (A) -> B) -> Fields<B>

The same fields, assembled into something else – the record, usually.

impl Shape
pub fn to_json_schema(self) -> Json

The shape as a JSON Schema document, draft 2020-12: what a model is prompted with, and what an API is documented by.

A Lazy shape is rendered once, under $defs by its name, and every mention after that is a $ref, which is what terminates a type that mentions itself. A rule renders as its keyword and its sentence. A Decimal renders as a string, which is what Encode writes and decimal reads. required leaves out an optional or defaulted field, and a secret is writeOnly.

impl Show for Shape
fn show(self) -> String
impl Decode for String
fn schema() -> Schema<String>
impl Decode for Int
fn schema() -> Schema<Int>
impl Decode for Float
fn schema() -> Schema<Float>
impl Decode for Bool
fn schema() -> Schema<Bool>
impl Decode for Decimal
fn schema() -> Schema<Decimal>
impl Decode for Raw
fn schema() -> Schema<Raw>
impl Decode for Json
fn schema() -> Schema<Json>
impl Decode for Date
fn schema() -> Schema<Date>
impl Decode for Time
fn schema() -> Schema<Time>
impl Decode for DateTime
fn schema() -> Schema<DateTime>
impl<A: Decode> Decode for Option<A>
fn schema() -> Schema<Option<A>>
impl<A: Decode> Decode for List<A>
fn schema() -> Schema<List<A>>
impl<A: Decode> Decode for Vector<A>
fn schema() -> Schema<Vector<A>>
impl<A: Decode> Decode for Redacted<A>
fn schema() -> Schema<Redacted<A>>
impl<V: Decode> Decode for Dict<String, V>
fn schema() -> Schema<Dict<String, V>>
impl<V: Decode> Decode for Map<String, V>
fn schema() -> Schema<Map<String, V>>
impl Encode for String
fn encode(self) -> Raw
impl Encode for Int
fn encode(self) -> Raw
impl Encode for Float
fn encode(self) -> Raw

A non-finite float writes null, because there is nowhere else to put it. JSON has no infinity and no NaN, and this wrote inf regardless – so derive(Encode) on a record with a Float field could produce a document schema::float() would not decode and no other parser would read either.

Json::of_float answers Option and lets the caller decide. A trait method has no such channel: encode owes a Raw and cannot refuse, so the choice is between a wrong number, a trap, and the one value JSON has for “not a number here”. null is that value, and it is what a decoder meeting the field will report as missing rather than as nonsense.

The test is a round trip through Float::of_string, whose grammar is JSON’s, so this and Json::of_float agree by construction.

impl Encode for Bool
fn encode(self) -> Raw
impl Encode for Decimal

Written as text, because that is how money travels on most wires: every decimal library this audience has used writes it so, for the sake of JavaScript clients whose only number is a double, and decimal reads it back.

fn encode(self) -> Raw
impl Encode for Raw
fn encode(self) -> Raw
impl Encode for Json
fn encode(self) -> Raw
impl Encode for Date
fn encode(self) -> Raw
impl Encode for Time
fn encode(self) -> Raw
impl Encode for DateTime
fn encode(self) -> Raw
impl<A: Encode> Encode for Option<A>

None is an absent key, and both an absent key and null read back as None.

fn encode(self) -> Raw
impl<A: Encode> Encode for List<A>
fn encode(self) -> Raw
impl<A: Encode> Encode for Vector<A>
fn encode(self) -> Raw
impl<V: Encode> Encode for Dict<String, V>
fn encode(self) -> Raw
impl<V: Encode> Encode for Map<String, V>
fn encode(self) -> Raw
impl Encode for Rejection

A problem as a client reads it: where, and what.

The one hand-written encoder over a type that holds a secret, and the reason it is safe is the reason Rejection::describe exists: the message never quotes a value that was under secret, and nothing else of the rejection is written.

fn encode(self) -> Raw
pub fn string() -> Schema<String>

Text, as it arrived.

A number is not text: { "name": 42 } is refused, as serde refuses it.

A source that could not tell hands over Raw::Untyped, which is read.

pub fn int() -> Schema<Int>

An Int, parsed from the token rather than through a float.

A rejection reads port should be a whole number, and is "eighty".

pub fn float() -> Schema<Float>

A Float, parsed from the token.

The fifteen significant digits a Float carries are the caller’s choice here, and are not made anywhere else in this module.

pub fn decimal() -> Schema<Decimal>

An exact decimal, parsed from the token.

The reason Raw::Number keeps its text: this is the one a price goes through, and a Float on the way would make it the wrong price.

Read from text as well as from a number. Money travels as a string on most wires, for the sake of JavaScript clients whose only number is a double, and every decimal library this audience has used writes it so.

pub fn bool() -> Schema<Bool>

True or false.

From a source that could not label it, true, false, 1 and 0 – and not yes, because a reader that accepts every spelling accepts a typo as false.

pub fn any() -> Schema<Raw>

Whatever arrived, as it arrived.

For a field whose shape is somebody else’s business. It refuses two things: Raw::Absent, a value that is not there, and Raw::Denied, one the manifest would not let anybody read.

pub fn optional<A>(inner: Schema<A>) -> Schema<Option<A>>

A value that may not be there.

Absent or null is None; present and the wrong shape is still an error, which is the distinction that matters: a misspelled setting should not read as one nobody set. A denied value is not None either, because a denied optional is still a line missing from khora.toml.

pub fn nullable<A>(inner: Schema<A>) -> Schema<Option<A>>

A value that must be there and may be null.

The field a JSON API sends as "ended": null to mean “not yet”, where leaving it out would be a different mistake.

pub fn default<A>(inner: Schema<A>, fallback: A) -> Schema<A>

A value, or fallback when it is not there.

Absent only. A value that is present and wrong is never quietly replaced, and neither is null: a client that sent "port": null said something, and the answer is a rejection rather than 8080.

pub fn list<A>(inner: Schema<A>) -> Schema<List<A>>

A list of them, with each element’s index in its own path.

pub fn dict<A>(inner: Schema<A>) -> Schema<Dict<String, A>>

A record whose keys are data: { "en": "Hello", "fr": "Bonjour" }.

Every value is read with inner, under its key, so a bad one reads greetings.fr should be text, and is 7.

pub fn refine<A>(inner: Schema<A>, must: String, holds: (A) -> Bool) -> Schema<A>

A condition on a value that is otherwise the right shape.

must is the rest of the sentence after must be, so write it as one: "between 1 and 65535" reads as port must be between 1 and 65535. The named rules below carry their bounds as structure instead, which is what a rendered document needs; this is for the rule the vocabulary lacks.

pub fn via<A, B: Show>(inner: Schema<B>, becomes: String, forward: (B) -> Option<A>) -> Schema<A>

A value whose wire form is not its domain form.

refine narrows a type to itself; this changes it. The wire carries a B and the program gets an A, so a timestamp may travel as a string, an amount as digits, an identifier as text – without the record that holds it knowing, and without a second pass over the decoded value to fix it up.

pub fn timestamp() -> Schema<Timestamp> {
via(string(), "Timestamp", fn text => Timestamp::parse(text))
}

Option rather than a Rejection, because the caller does not know where in the document it is standing; the path belongs to the schema and is attached here. A None reads exactly like a primitive that did not match – wanted a Timestamp, found "not a date" – which is the point: a value that arrives in the wrong form should not report differently depending on whether the conversion was built in.

One direction only, and deliberately. Encode is a separate trait for the reason its own documentation gives – a Redacted decodes and has no wire form at all – so a type built with this implements Encode itself if it has one. Folding a back in here would promise a round trip that Redacted is a standing counterexample to.

pub fn between<A: Ord + Show>(inner: Schema<A>, low: A, high: A) -> Schema<A>

A value between two bounds, inclusive: between(int(), 1, 65535).

pub fn at_least<A: Ord + Show>(inner: Schema<A>, bound: A) -> Schema<A>

A value no smaller than bound.

pub fn at_most<A: Ord + Show>(inner: Schema<A>, bound: A) -> Schema<A>

A value no larger than bound.

pub fn min_length(inner: Schema<String>, n: Int) -> Schema<String>

Text of at least n characters.

pub fn max_length(inner: Schema<String>, n: Int) -> Schema<String>

Text of at most n characters.

pub fn min_items<A>(inner: Schema<List<A>>, n: Int) -> Schema<List<A>>

A list of at least n items.

pub fn max_items<A>(inner: Schema<List<A>>, n: Int) -> Schema<List<A>>

A list of at most n items.

pub fn non_empty<A>(inner: Schema<List<A>>) -> Schema<List<A>>

A list with something in it.

pub fn one_of<A: Eq + Show>(inner: Schema<A>, allowed: List<A>) -> Schema<A>

A value from a closed set: one_of(string(), ["gzip", "br"]).

pub fn secret<A>(inner: Schema<A>) -> Schema<Redacted<A>>

A value nothing may print.

A combinator over any inner schema, not a leaf. secret(int()) and secret(string()) both work, and redaction composes with everything else instead of sitting beside it. No bound on A: a Schema<A> is a value, and holding one already is the evidence that an A can be decoded.

A failure inside one is marked, and Rejection::describe then says what was wanted without quoting what it saw.

pub fn key<A>(wire: String, inner: Schema<A>) -> Schema<A>

A field read under a key that is not its name: key("userId", int()).

For the wire whose spelling the type cannot use. The rejection names the wire key, because that is the one the client sent.

pub fn renamed<A>(inner: Schema<A>, wire: String, field: String) -> Schema<A>

A record schema with one of its keys spelled differently on the wire: renamed(Settings::schema(), "userId", "user_id") reads userId into the field user_id.

For the one odd key in a record that is otherwise fine as declared. The rejection names the wire key.

pub fn struct<A>(fields: Fields<A>) -> Schema<A>

A record schema, from a record literal of schemas.

struct({ host: string(), port: between(int(), 1, 65535) }) is a Schema<Listen> when Listen is the record with those fields. Which record it is comes from the type the expression is asked for – an annotation, a parameter, the declared return type – or from the labels alone when only one record has them, the way it does for any record literal. A field whose schema decodes the wrong type is reported at that schema; a field the record does not have, or one it has and the literal does not, is reported at the call.

Not a function that runs. Its argument is a record of schemas and its result a schema of the record they decode, and there is no type-level map from one to the other. A call to it is rewritten before it is typed, into Schema::record over Fields – which is what a hand-written record schema is, and what a derived one is too. The declaration is here so the name can be imported and read about; a call with anything but a record literal, or a use of the name as a value, is refused.

Most records need no literal at all: derive(Decode) writes the same thing from the declaration. The literal is for the record that needs something the declaration cannot say – a refined port, a renamed key, a default – and every schema that contains the record picks it up through the trait.

pub fn decode<A: Decode>(from: Raw) -> Validated<A, Rejection>

Reads a value of the type the surrounding expression asks for.

let settings: Validated<Settings, Rejection> = decode(raw); chooses the schema from the annotation; Settings::schema().decode(raw) is the same call with the type named.

Khora 0.2.0built frombd964738afb6on 2026-10-17 20:41ZVersioningSecurityContributingReleasesSource