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.
Both halves, in one record
Section titled “Both halves, in one record”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.
Every problem, not the first
Section titled “Every problem, not the first”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.
API at a glance
Section titled “API at a glance”Types: Raw · Segment · Rule · Problem · Rejection · Shape · Named · Alternative · Schema · Fields · Case
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.
Segment
Section titled “Segment”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.
Problem
Section titled “Problem”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.
Rejection
Section titled “Rejection”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.
Alternative
Section titled “Alternative”derive(Show)pub type Alternative = { name: String, fields: List<Named> };One case of a Shape::Cases: its tag, and the fields of its payload.
Schema
Section titled “Schema”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.
Fields
Section titled “Fields”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.
Traits
Section titled “Traits”Decode
Section titled “Decode”pub trait DecodeA 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.
schema
Section titled “schema”fn schema() -> Schema<Self>The schema for Self, chosen by the type the call is asked for.
Encode
Section titled “Encode”pub trait EncodeA 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.
encode
Section titled “encode”fn encode(self) -> RawThe value as a source would have produced it; Raw::to_json is the
bridge out.
Methods
Section titled “Methods”impl Rawpub 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) -> RawThe 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.
describe
Section titled “describe”pub fn describe(self) -> StringWhat to call this in a message: text, a number, and so on.
of_json
Section titled “of_json”pub fn of_json(document: Json) -> RawA 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.
to_json
Section titled “to_json”pub fn to_json(self) -> JsonThe 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.
of_map
Section titled “of_map”pub fn of_map(text: Map<String, String>) -> RawA record of text nobody labelled: a query string, the path parameters, the headers.
of_arguments
Section titled “of_arguments”pub fn of_arguments(arguments: List<String>) -> RawCommand-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.
of_arguments_for
Section titled “of_arguments_for”pub fn of_arguments_for(shape: Shape, arguments: List<String>) -> RawCommand-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 Ruledescribe
Section titled “describe”pub fn describe(self) -> StringThe rule as the rest of a sentence beginning must: be between 1 and 65535, have at least 1 item.
Segment
Section titled “Segment”impl Segmentpub fn write(self) -> StringThe step as it appears in a path: .name or [3].
Rejection
Section titled “Rejection”impl Rejectionpub fn at(self) -> StringWhere 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.
describe
Section titled “describe”pub fn describe(self) -> StringOne 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.
describe_as
Section titled “describe_as”pub fn describe_as(self, at: String) -> StringThe 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.
report
Section titled “report”pub fn report(problems: List<Rejection>) -> StringEvery problem, one per line, in the order they were found.
impl Shapepub 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.
wire_key
Section titled “wire_key”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.
Schema<A>
Section titled “Schema<A>”impl<A> Schema<A>decode
Section titled “decode”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).
decode_or_stop
Section titled “decode_or_stop”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)).
try_map
Section titled “try_map”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".
record
Section titled “record”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.
cases_tagged
Section titled “cases_tagged”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.
closed
Section titled “closed”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.
described
Section titled “described”pub fn described(self, text: String) -> Schema<A>The same schema, with a sentence for a document to carry.
Fields<A>
Section titled “Fields<A>”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 Shapeto_json_schema
Section titled “to_json_schema”pub fn to_json_schema(self) -> JsonThe 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.
Trait implementations
Section titled “Trait implementations”Show for Shape
Section titled “Show for Shape”impl Show for Shapefn show(self) -> StringDecode for String
Section titled “Decode for String”impl Decode for Stringschema
Section titled “schema”fn schema() -> Schema<String>Decode for Int
Section titled “Decode for Int”impl Decode for Intschema
Section titled “schema”fn schema() -> Schema<Int>Decode for Float
Section titled “Decode for Float”impl Decode for Floatschema
Section titled “schema”fn schema() -> Schema<Float>Decode for Bool
Section titled “Decode for Bool”impl Decode for Boolschema
Section titled “schema”fn schema() -> Schema<Bool>Decode for Decimal
Section titled “Decode for Decimal”impl Decode for Decimalschema
Section titled “schema”fn schema() -> Schema<Decimal>Decode for Raw
Section titled “Decode for Raw”impl Decode for Rawschema
Section titled “schema”fn schema() -> Schema<Raw>Decode for Json
Section titled “Decode for Json”impl Decode for Jsonschema
Section titled “schema”fn schema() -> Schema<Json>Decode for Date
Section titled “Decode for Date”impl Decode for Dateschema
Section titled “schema”fn schema() -> Schema<Date>Decode for Time
Section titled “Decode for Time”impl Decode for Timeschema
Section titled “schema”fn schema() -> Schema<Time>Decode for DateTime
Section titled “Decode for DateTime”impl Decode for DateTimeschema
Section titled “schema”fn schema() -> Schema<DateTime>Decode for Option<A>
Section titled “Decode for Option<A>”impl<A: Decode> Decode for Option<A>schema
Section titled “schema”fn schema() -> Schema<Option<A>>Decode for List<A>
Section titled “Decode for List<A>”impl<A: Decode> Decode for List<A>schema
Section titled “schema”fn schema() -> Schema<List<A>>Decode for Vector<A>
Section titled “Decode for Vector<A>”impl<A: Decode> Decode for Vector<A>schema
Section titled “schema”fn schema() -> Schema<Vector<A>>Decode for Redacted<A>
Section titled “Decode for Redacted<A>”impl<A: Decode> Decode for Redacted<A>schema
Section titled “schema”fn schema() -> Schema<Redacted<A>>Decode for Dict<String, V>
Section titled “Decode for Dict<String, V>”impl<V: Decode> Decode for Dict<String, V>schema
Section titled “schema”fn schema() -> Schema<Dict<String, V>>Decode for Map<String, V>
Section titled “Decode for Map<String, V>”impl<V: Decode> Decode for Map<String, V>schema
Section titled “schema”fn schema() -> Schema<Map<String, V>>Encode for String
Section titled “Encode for String”impl Encode for Stringencode
Section titled “encode”fn encode(self) -> RawEncode for Int
Section titled “Encode for Int”impl Encode for Intencode
Section titled “encode”fn encode(self) -> RawEncode for Float
Section titled “Encode for Float”impl Encode for Floatencode
Section titled “encode”fn encode(self) -> RawA 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.
Encode for Bool
Section titled “Encode for Bool”impl Encode for Boolencode
Section titled “encode”fn encode(self) -> RawEncode for Decimal
Section titled “Encode for Decimal”impl Encode for DecimalWritten 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.
encode
Section titled “encode”fn encode(self) -> RawEncode for Raw
Section titled “Encode for Raw”impl Encode for Rawencode
Section titled “encode”fn encode(self) -> RawEncode for Json
Section titled “Encode for Json”impl Encode for Jsonencode
Section titled “encode”fn encode(self) -> RawEncode for Date
Section titled “Encode for Date”impl Encode for Dateencode
Section titled “encode”fn encode(self) -> RawEncode for Time
Section titled “Encode for Time”impl Encode for Timeencode
Section titled “encode”fn encode(self) -> RawEncode for DateTime
Section titled “Encode for DateTime”impl Encode for DateTimeencode
Section titled “encode”fn encode(self) -> RawEncode for Option<A>
Section titled “Encode for Option<A>”impl<A: Encode> Encode for Option<A>None is an absent key, and both an absent key and null read back as
None.
encode
Section titled “encode”fn encode(self) -> RawEncode for List<A>
Section titled “Encode for List<A>”impl<A: Encode> Encode for List<A>encode
Section titled “encode”fn encode(self) -> RawEncode for Vector<A>
Section titled “Encode for Vector<A>”impl<A: Encode> Encode for Vector<A>encode
Section titled “encode”fn encode(self) -> RawEncode for Dict<String, V>
Section titled “Encode for Dict<String, V>”impl<V: Encode> Encode for Dict<String, V>encode
Section titled “encode”fn encode(self) -> RawEncode for Map<String, V>
Section titled “Encode for Map<String, V>”impl<V: Encode> Encode for Map<String, V>encode
Section titled “encode”fn encode(self) -> RawEncode for Rejection
Section titled “Encode for Rejection”impl Encode for RejectionA 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.
encode
Section titled “encode”fn encode(self) -> RawFunctions
Section titled “Functions”string
Section titled “string”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.
decimal
Section titled “decimal”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.
optional
Section titled “optional”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.
nullable
Section titled “nullable”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.
default
Section titled “default”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.
refine
Section titled “refine”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.
between
Section titled “between”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).
at_least
Section titled “at_least”pub fn at_least<A: Ord + Show>(inner: Schema<A>, bound: A) -> Schema<A>A value no smaller than bound.
at_most
Section titled “at_most”pub fn at_most<A: Ord + Show>(inner: Schema<A>, bound: A) -> Schema<A>A value no larger than bound.
min_length
Section titled “min_length”pub fn min_length(inner: Schema<String>, n: Int) -> Schema<String>Text of at least n characters.
max_length
Section titled “max_length”pub fn max_length(inner: Schema<String>, n: Int) -> Schema<String>Text of at most n characters.
min_items
Section titled “min_items”pub fn min_items<A>(inner: Schema<List<A>>, n: Int) -> Schema<List<A>>A list of at least n items.
max_items
Section titled “max_items”pub fn max_items<A>(inner: Schema<List<A>>, n: Int) -> Schema<List<A>>A list of at most n items.
non_empty
Section titled “non_empty”pub fn non_empty<A>(inner: Schema<List<A>>) -> Schema<List<A>>A list with something in it.
one_of
Section titled “one_of”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"]).
secret
Section titled “secret”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.
renamed
Section titled “renamed”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.
struct
Section titled “struct”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.
decode
Section titled “decode”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.3.0built frombd964738afb6on 2026-10-17 20:41ZVersioningSecurityContributingReleasesSource