Schemas & Types
Stringent’s constraints, result types, and schemas are
arktype definitions — "number", "string | number",
"number > 0", "string.email", { min: "number", max: "number" }. Types
drive parsing, not just results: when an operand slot constrained to
number sees a string expression, the parser backtracks and tries another
interpretation, in both engines.
The type environment
Section titled “The type environment”Each parser owns a compiled type environment: an arktype scope containing
the keyword library plus any aliases you pass as
createParser(nodes, { scope }):
const parser = createParser(nodes, { scope: { quantity: "number" }, // "quantity" now usable in schemas});Every distinct definition is compiled once per parser and cached. Schemas
that use scope aliases resolve at compile time and runtime alike — a
leaf like { total: "quantity" } validates, and total types as number
in literal mode, with no casts.
Validation happens at three layers:
- Compile time, at the definition site — the pattern builder
validates every constraint and result def where it is written, against
the chain’s bindings; a typo’d
operand("nmbr")is a compile error at that call. Schema leaves are checked via arktype’stype.validate(in the parser’s scope) on every entry point, so{ x: "numbr" }errors at the leaf whether you callsafeParse,compile,parse, orevaluate. - Construction time —
createParserre-checks everything for plain-JS callers, plus the cross-element and cross-node rules types cannot see, and throws with a precise message. - Runtime — schemas are compiled in the parser’s scope, covering
dynamically-built schemas;
safeParsereturns a structuredINVALID_SCHEMAerror instead of throwing.
Matching is assignability
Section titled “Matching is assignability”An operand satisfies a slot when its parsed type is assignable to the
constraint — not when the names match. "number > 0" satisfies a "number"
slot; "string" satisfies "string | number". There is no name equality
anywhere.
Refinements are validation-only. Refined definitions like "number > 0"
or "string.email" erase to their base types for expression typing — in
both engines — so a refinement never changes what parses. Refinements do
their real job at the values boundary: evaluate() and
compiled rules
validate the values object against the full, un-erased schema.
Nested schemas & member access
Section titled “Nested schemas & member access”Schemas can nest; the path() element matches dotted paths and resolves their
types by walking the schema — at compile time and runtime:
const schema = { values: { password: "string", confirmPassword: "string" } } as const;
const [ast] = parser.parse("values.password == values.confirmPassword", schema);// left/right are PathNode<["values", "password"], "string">, result is "boolean"Path syntax is strict: values.password is valid, but whitespace around the
dot (values . password) and dangling dots (values.) are not. Lookups are
own-property only — __proto__, constructor and friends never resolve to
prototype internals, at parse time or eval time.
Polymorphic nodes
Section titled “Polymorphic nodes”Three primitives replace per-type node variants:
- Union constraints —
operand("number | string"): any arktype union definition (operator overloading). - Binding references —
operand("left")orrest("left"): a constraint that names an earlier binding means “assignable to whateverleftparsed as” (directional: candidate ⊆ left). For equality-style operators where operand order must not matter, use the symmetric formrest(overlapping("left"))— the two types must overlap. - Derived result types —
resultType: "left": the node’s result type is whatever the operand bound asleftparsed as, per parse.
Together they give polymorphic operators without per-type variants:
// number+number → number, string+string → string, number+string → TYPE_MISMATCHconst add = defineNode({ name: "add", precedence: 2, pattern: (p) => p .operand("number | string").as("left") .constVal("+") .operand("left").as("right") .result("left") .eval((b) => { const left = b.left(); const right = b.right(); return typeof left === "string" ? `${left}${String(right)}` : Number(left) + Number(right); }),});The derived output type is computed per-parse, so parser.evaluate("'a'+'b'", …)
is typed string while parser.evaluate("1+2", …) is typed number.
References also work embedded in larger defs — rest("left | null")
constrains the operand to “whatever left parsed as, or null”, and
resultType: "left | null" derives a union result the same way. The def
is resolved per parse in a scope extended with the parsed operand types.
Polymorphic evals & match
Section titled “Polymorphic evals & match”eval’s parameter is a flat per-binding map: a binding-reference
constraint resolves to the referenced operand’s constraint type.
pattern: [operand("number | string").as("left"), constVal("+"), operand("left").as("right")]// eval receives: { left: string | number; right: string | number } (as thunks)For polymorphic evals, the idiomatic style is arktype’s match — one case
per accepted combination, .default("assert") rejecting the rest:
import { match } from "arktype";import { type InferEvaluatedBindings } from "stringent";
const addPattern = [ operand("number | string").as("left"), constVal("+"), operand("left").as("right"),] as const;
const addImpl = match .in<InferEvaluatedBindings<typeof addPattern>>() .case({ left: "number", right: "number" }, (b) => b.left + b.right) .case({ left: "string", right: "string" }, (b) => b.left + b.right) .default("assert");
const add = defineNode({ name: "add", pattern: addPattern, precedence: 2, resultType: "left", // bindings are thunks — evaluate them, then match eval: (b) => addImpl({ left: b.left(), right: b.right() }),});The runtime backstop matters because values can straddle the accepted
combinations when a union-typed schema identifier fills either side
(x + 1 with x: "string | number" holding a string) — .default("assert")
turns the mixed case into a runtime error instead of a silent "hi1".
The "unknown" type
Section titled “The "unknown" type”"unknown" is the type of unresolved identifiers and paths. Constrained
slots reject it, which is how “identifier not in schema” surfaces as a
TYPE_MISMATCH naming the offender.
Unconstrained slots — and symmetric overlapping checks against
unconstrained operands — accept unknown operands: zz == yy with an empty
schema parses and only fails at evaluation. If a grammar wants unresolved
identifiers rejected structurally, constrain its slots.
Constraint forms at a glance
Section titled “Constraint forms at a glance”| Form | Meaning |
|---|---|
operand() |
unconstrained |
operand("number"), operand("string | number") |
any arktype definition in the parser’s scope |
operand("left"), rest("left") |
binding reference — assignable to whatever the earlier operand left parsed as |
rest(overlapping("left")) |
symmetric binding reference — the types must overlap (equality-style operators) |