Escape
HomeDocsValidating definitions

Validating definitions

Intent

A definition lives outside your type system. Nothing tells you it broke until someone renders it, which in practice means a customer opening an invoice. This is how to compile one on purpose, read what came back, and hold the result for as long as your service runs.

Design decisions

plan is one traversal read three ways. A host that validates and renders in a loop, such as an editor, would otherwise descend the document twice. plan returns the compiled report, the structured problems, and the anchors each compiled source reads, all from a single descent.

report throws on the first problem. It’s the call for a definition you already trust. When you want every problem at once, ask plan or validate instead of catching in a loop.

validate flattens the same problems into strings. Its output is exactly each problem’s message. That makes it the right call for a build script that prints and exits. It’s the wrong one for anything that needs the location as data.

A problem keeps its own offsets. Every problem carries its own diagnostic where an engine authenticated the fault, not merely the first. An editor underlining the offending characters needs all of them.

Unknown keys come before the problems beneath them. A mistyped key has no position in the documented shape to slot into, so it’s reported ahead of the known keys it sits among. Every schema object with fixed keys is a closed set, which is why visable is an error rather than an ignored field.

isDiagnostic answers for the delegated engines only. It’s true for a located diagnostic raised by the expression, template or query engines, and authenticated by identity rather than by shape. An error that merely looks like one doesn’t pass.

quario’s own verdicts carry no code. An unknown reducer, a fold handed something it can’t reduce, image bytes the engine won’t vouch for. These are quario’s judgements about your document, and they carry structured path and source instead of an engine’s error code. Branch on those.

No guard separates a verdict from a registered function’s throw. The two carry identical locations, so the engine can’t answer the question and declines to pretend otherwise. A host that needs the distinction throws its own error class from its own functions.

A warning isn’t a problem at lower severity. A warning names a declaration the engine will quietly drop, reported after every compile has already succeeded. A document carrying only warnings compiles and renders exactly as before, and a host that ignores the field notices nothing.

Compiling is the expensive call. Every expression in the document parses into a closure at compile time. Rendering walks data through closures that already exist, which is why the compiled report is the thing worth holding.

API walkthrough

Checking a definition before it renders

q.plan(schema, funcs?) returns { report, problems, anchors, warnings }. report is the compiled report, or null while the document has problems. anchors maps each compiled source’s schema path to the anchors and handles it reads, so a tool can tell whether a node may move. A placement is safe only where every anchor that node reads binds there.

plan.warnings lists declarations nothing will read, in document order, each { path, message }. They carry no source and no diagnostic, because nothing raised. Three today. One is a format on a run that never renders a single value. Another is a currency on a cell not formatted as one. The third is a table whose fixed widths total under 100. The list is deliberately incomplete, since it sees the definition alone, without data. validate() doesn’t change, and returns problems only.

validate(schema, funcs?) returns the problems alone, as strings, in document order. It’s a root export rather than an instance method, because checking a document’s shape needs no instance configuration.

q.report(schema, funcs?) compiles and throws on the first problem.

check.js
import { validate } from 'quario'

const problems = validate(definition, {})

for (const message of problems) console.error(message)
process.exitCode = problems.length ? 1 : 0

Run this over the definitions you ship, in the same job that runs your tests. It needs no data and no render, so it costs a second and catches the class of breakage that otherwise waits for a customer.

Reading a problem

problem.path is the schema path the fault sits at, such as detail.columns[3].value. It tells you which node to open.

problem.source is the author source behind it, present when the problem came from compiling one.

problem.message is the whole located sentence, and it’s what validate returns for that problem.

problem.diagnostic is the engine’s own located error, present when an engine authenticated the fault. Its offsets are zero-based into the source shown in brackets, with end exclusive.

check.js
const { report, problems, warnings } = q.plan(definition, {})

for (const problem of problems) {
  console.error(`${problem.path}: ${problem.message}`)
  if (problem.source) console.error(`  in ${problem.source}`)
}
for (const warning of warnings) console.warn(`${warning.path}: ${warning.message}`)

if (!report) process.exitCode = 1

The engine freezes problems, so a reporting pass can’t alter what it’s describing.

Decide your own severity. This site's own build fails on a warning, because a documented declaration that does nothing is documentation that’s wrong.

Telling an engine’s fault from your own

isDiagnostic(e) is true for a located diagnostic and nothing else. What it buys you is trust in the code and the offsets on that error. They’re an engine’s, not a lookalike’s.

code, start and end come from whichever engine decided the fault. Expression errors preserve the expression engine’s, and template errors use absolute offsets into the displayed template. A traversal budget exceeded at render time carries limit and actual instead of offsets.

A registered function’s throw keeps its class behind the path it failed at. isDiagnostic is false for it, and so is any test for quario’s own verdicts.

Your own error class is the way to mark your own failures. It must be the class that carries the marker. The engine rebuilds a located copy from the constructor and the message, so a field you set on the instance doesn’t survive.

render.js
import { isDiagnostic } from 'quario'

try {
  await report.render(target, data)
} catch (error) {
  if (isDiagnostic(error)) console.error(error.code, error.start, error.end)
  else throw error
}

Diagnostics sit outside the frozen authoring surface, so their codes may change with the engines behind them. Log them, and don’t build control flow on a particular code.

Holding a compiled report

A compiled report is the object your service keeps between requests. Compile it at startup, or the first time something asks for a definition, and hand render a fresh dataset per request.

report.stream(data) is the raw event seam, one generator of report events per call. It’s what a custom target consumes and what the official targets sit behind.

report.names is the union of free variables across every expression in the document. It leaves out engine anchors and group handles, so what’s left is the set of names your registry has to satisfy.

report.functions lists the registry functions the document calls, in call-first-seen order. Each carries a name, an arity, and its own doc string where it has one.

report.paths is the query topology behind data. Metadata read through $.input doesn’t change it.

What invalidates a held report is the definition and the registry behind it. Change either and compile again. Nothing invalidates it on its own, and no data you render through it can.

The instance matters as much as the definition. Presentation reads the instance’s locale, currency and timezone, so the same definition compiled by an en-IE instance and a de-DE one renders €1,234.50 and 1.234,50 $. A service presenting in several locales holds an instance, and a compiled report, for each.

Compile it before your customer does.

npm install quario
Getting started
© 2026 quario · KvK 61815977