Skip to content

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.

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:

  1. 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’s type.validate (in the parser’s scope) on every entry point, so { x: "numbr" } errors at the leaf whether you call safeParse, compile, parse, or evaluate.
  2. Construction timecreateParser re-checks everything for plain-JS callers, plus the cross-element and cross-node rules types cannot see, and throws with a precise message.
  3. Runtime — schemas are compiled in the parser’s scope, covering dynamically-built schemas; safeParse returns a structured INVALID_SCHEMA error instead of throwing.

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.

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.

Three primitives replace per-type node variants:

  • Union constraintsoperand("number | string"): any arktype union definition (operator overloading).
  • Binding referencesoperand("left") or rest("left"): a constraint that names an earlier binding means “assignable to whatever left parsed as” (directional: candidate ⊆ left). For equality-style operators where operand order must not matter, use the symmetric form rest(overlapping("left")) — the two types must overlap.
  • Derived result typesresultType: "left": the node’s result type is whatever the operand bound as left parsed as, per parse.

Together they give polymorphic operators without per-type variants:

// number+number → number, string+string → string, number+string → TYPE_MISMATCH
const 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.

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".

"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.

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)