bend-schema
v0.5.2
Published
A schema library whose checker is **proved correct**, not just tested.
Readme
bend-schema
A schema library whose checker is proved correct, not just tested.
Define a schema once. Check JSON against it from TypeScript or from Bend, and get the first error with its path. The checker is written in Bend, and its laws are machine-checked for every schema and every value.
The npm package and the GitHub repository are bend-schema. The BendHub
package is bend-schema-lib.
Install
bun add bend-schemaFrom a Bend file there is no install step: import the checker from the hub.
import [email protected]/core.bend as SUse it with Bun. The package ships TypeScript source, and
Node.js refuses to run TypeScript from node_modules. You do not need Bend
installed.
The bend-schema command emits Bend source, and it runs under Bun whether you
start it with bunx or npx: its shebang is env bun.
Quick start
import { s, type Infer } from "bend-schema";
const Plan = s.object({
name: s.str().len(1, 64),
seats: s.nat().in(1, 500),
tier: s.enum(["free", "pro"]),
email: s.str().refine((x) => x.includes("@"), "must be an email"),
note: s.str().optional(),
}).strict();
type Plan = Infer<typeof Plan>;
const r = Plan.parse(JSON.parse(body));
if (!r.ok) {
console.error(r.error.text("plan")); // "plan.seats: must be from 1 to 500"
}Every schema has three methods:
parse(value)checks the value, then returns it typed.check(value)returns the first error, ornull.encode(value)turns a typed value back into JSON-ready data.
An error has a path, a message, and proved. proved: true means the
error came from the verified core. proved: false means it came from your
own .refine() function, which runs only after the proved check passes.
Errors on the wire
error.toJSON() is the error as JSON, and JSON.stringify(error) writes the
same thing:
{ "path": ["params", 1, "n"], "message": "must be a whole number from 0 to 281474976710655", "proved": true }pathlists object keys (strings) and list indices (whole numbers from 0 to 2^48-1), outer first. An empty path means the value itself.- The field names and their types are stable: a change to them is a major
version. The
messagetext is not stable. It is written for people, so do not branch on it. issueSchemais the schema of this form, so the receiver can check it like any other value. It is not strict: a field added later does not break an older reader. Each path part is a string or a whole number, as a proveds.union.
For example, as the data of a JSON-RPC "Invalid params" error:
const r = Params.parse(msg.params);
if (!r.ok) return { jsonrpc: "2.0", id: msg.id, error: { code: -32602, message: "Invalid params", data: r.error.toJSON() } };Schemas
| Builder | Accepts |
|---|---|
| s.nat(), .in(lo, hi) | integer 0 to 2^48-1 |
| s.int(), .in(lo, hi) | integer -(2^48-1) to 2^48-1 |
| s.str(), .len(lo, hi) | string |
| s.bool(), s.true() | boolean, or only true |
| s.enum([...]) | one of the given strings |
| s.list(x), .len(lo, hi) | array of x |
| s.tuple(a, b, ...) | fixed-length array |
| s.object({...}), .strict() | object; .strict() refuses unknown keys |
| s.tagged(key, {...}) | union chosen by a tag key |
| s.oneKey({...}) | object with exactly one of the keys |
| s.union(a, b, ...) | one of the alternatives, which take different JSON kinds |
| s.json() | any JSON value, passed through unchanged |
| .optional() | the object key may be absent |
| .nullable() | the value may be null |
| .refine(fn, message) | a custom check (not proved) |
See docs/schemas.md for how to write schemas: objects, unions, custom rules, error messages and encoding.
For why s.json() stores and checks a value the way it does, see
docs/design/json-values.md.
Optional Effect v4 adapter
Convert an existing schema into an Effect codec (a schema with decoding and
encoding). The adapter is a separate entry point; importing bend-schema
does not load or require Effect.
bun add bend-schema [email protected]import { s } from "bend-schema";
import { toEffect } from "bend-schema/effect";
import { Schema } from "effect";
import { Rpc } from "effect/rpc";
const Message = toEffect(s.object({ text: s.str(), note: s.str().optional() }));
const decoded = Schema.decodeUnknownSync(Message)({ text: "hello", extra: true });
// decoded is inferred as { text: string; note?: string }; extra is dropped.
const encoded = Schema.encodeSync(Message)(decoded);
const Echo = Rpc.make("Echo", { payload: Message, success: Message });The API infers T from the input schema; no type assertion is needed:
function toEffect<T>(schema: Schema<T>): EffectSchema.decodeTo<
EffectSchema.declareConstructor<T, T, readonly []>, typeof EffectSchema.Unknown
>;
// Schema is bend-schema's type; EffectSchema is effect's Schema module.The concrete return type keeps Effect's constructor input (~type.make.in)
as T. RPC clients therefore accept the inferred object, tagged union, or
string payload and reject incorrectly typed fields at compile time. Widening
the adapter to EffectSchema.Codec<T, unknown> erases that constructor input
to unknown; keep the inferred return type when passing it to Rpc.make.
TypeScript checks field types, not refinement predicates or numeric ranges;
those are still validated at runtime.
Decoding returns schema.parse(input).value, not the untouched input.
Encoding validates the decoded value with parse, then calls schema.encode.
Both directions are synchronous and require no Effect services.
- Errors: the first parse error keeps its path and reason in an Effect
SchemaIssue.PointerandInvalidValue. Effect adds surrounding field paths when the codec is nested.provedis on theInvalidValueannotations, next tomessage. Effect's own formatters print only the path and the message. - Semantics: unknown keys are dropped unless the object is strict. Optional fields, tagged unions, refinements and the existing size limits still apply. Effect parse options do not replace bend-schema's validation rules.
- Representation: the encoded type is
unknown, as withschema.encode. The Effect AST uses an opaque declaration, not a generated structural schema, so Effect's ownSchema.toJsonSchemaDocumentsees{}. For a JSON Schema, usebend-schema/json-schema(below). No Bend datatype parser or source generator is involved. - Failures: thrown schema configuration errors and thrown refinement
callbacks remain programming errors, not validation issues. Exceptions from
encodebecome Effect validation issues with the exception's message.
Effect is an optional peer dependency (^4.0.1); tests pin 4.0.1.
Effect v3 and v4 prereleases are not supported by this adapter.
JSON Schema
toJsonSchema writes a JSON Schema (draft 2020-12) document for the JSON a
schema accepts, for client authors and tools that read JSON Schema:
import { toJsonSchema } from "bend-schema/json-schema";
const doc = toJsonSchema(s.object({ id: s.nat(), name: s.str().optional() }));
// { $schema: "https://json-schema.org/draft/2020-12/schema", type: "object",
// properties: { id: { type: "integer", minimum: 0, maximum: 281474976710655 },
// name: { type: "string" } },
// required: ["id"], additionalProperties: true }- Never stricter than
parse. Every valueparseaccepts passes the document. Three things JSON Schema cannot state are left out, and there the document is looser:.refine()predicates, the depth limit, and a key repeated within one object.parsestays the authority. - Exact elsewhere. Without
.refine()and inside the depth limit, a JSON value passes the document exactly whenparseaccepts it. The tests check this against ajv on random schemas and values; it is tested, not proved. - Mapping.
nat,intand.in()are boundedintegers;.len()isminLength/maxLength(both count code points) orminItems/maxItems; a tuple isprefixItemswith no further items;.strict()isadditionalProperties: false;taggedandoneKeyareoneOf;unionisanyOf;s.json()is{}. - ajv and
__proto__. ajv 8 skips apropertiesentry named__proto__, so it does not check a field of that name. The document itself names it.
Examples
Each example is a small runnable project with tests:
examples/api-server: an HTTP endpoint that validates the request body and returns the error path in a 400.examples/config-loader: reads a JSON config file and reports the first mistake in one line.examples/event-log: an append-only JSON-lines log that writes and reads with the same schema.
Ask an AI agent to write a schema
New to bend-schema? Paste this into your coding agent to introduce it, then work with the agent as usual:
bend-schema is a TypeScript schema library like zod, whose checker is
written in Bend and proved correct. Read its README to learn what it is
and how to install it: https://github.com/nohzafk/bend-schema
If we decide to use it, docs/schemas.md in that repo explains how to
write schemas.Use from Bend
Write a schema once in TypeScript, use it in a Bend function whose laws you prove, and call that function from TypeScript again. The loop is:
schema.ts ──gen──▶ schema.bend ──import──▶ core.bend ──bend-emit──▶ dist/core.mjs ──import──▶ app.ts1. Export your schemas from a TypeScript module:
// schema.ts
import { s } from "bend-schema";
export const Config = s.object({
name: s.str().len(1, 64),
seats: s.nat().in(1, 500),
}).strict();
export const schemas = { config: Config };2. Generate Bend source. The command executes schema.ts, so that file
should contain only schemas. It runs under Bun — Node cannot strip the types
from TypeScript in node_modules.
bunx bend-schema gen schema.ts schema.bendschema.bend defines config_schema() and imports the proved checker from
this package's core/core.bend.
3. Use the schema in your own Bend core. Import the checker with the same
path that schema.bend uses on its second line:
# core.bend
import Base
import ./node_modules/bend-schema/core/core.bend as S
import ./schema.bend as Sch
def config_ok(r: S.Raw) -> Bool:
S.conforms0(Sch.config_schema(), r)Here you can state and prove laws about your own functions, and build on the
laws in core/LAWS.bend. Import core/PROOF.bend alongside them: its proofs
are what close them, and a file that imports LAWS.bend alone does not check.
The test suite proves it.
4. Turn the core into a typed ES module with bend-emit:
bun add -d bend-emit
bunx bend-emit core.bend dist # writes dist/core.mjs and dist/core.d.mts5. Call it from TypeScript. toRaw converts a JSON value into the
core's Raw input:
// app.ts
import { toRaw } from "bend-schema";
import { config_ok } from "./dist/core.mjs";
config_ok(toRaw({ name: "a", seats: 3 })); // true
config_ok(toRaw({ name: "a", seats: 999 })); // falseCommit dist/: at run time your package needs neither Bend nor bend-emit.
Bend code can also plug in its own rules through a template parameter. The
laws hold for every rule, so you only need to prove what your rule means. See
core/core.bend for the full schema type and the rule interface.
What is proved
The laws in core/LAWS.bend hold for every schema, every rule and every value:
- Exact:
checkreports no error if and only if the value conforms. - Accurate: the reported path leads to a real error of the reported kind.
- Round trip: decoding an encoded value gives back the same value.
- Encoding conforms: what the encoder writes passes the check whenever each enum value is one of its names and each bound holds of the value.
- Any JSON value:
s.json()accepts exactly the valid JSON values (every number finite), and decoding and encoding keep the value unchanged. - Union:
s.unionaccepts exactly what one alternative accepts, never two at once, and reports the error of the alternative whose JSON kind the value has. - Each combinator (enum, tuple, variant, tagged union, strict, bounds) has a law that states what it accepts.
Not proved: the TypeScript builder, the conversion between JS values and the
core, and .refine() predicates. These are covered by tests.
Two JavaScript details are handled in the TypeScript layer, because the core
never sees them: a non-plain object (a Date, Map or class instance) is
refused as not being JSON, and an object key named __proto__ is an ordinary
field, read and written as data, never as the prototype.
Limits
- Depth: containers may nest at most 128 levels; the outermost array or
object is level 1. A container on level 129 is
TooLargeat its path. - Width is not limited. A list of any length and an object of any number of keys are checked; strings are not inspected for size.
Depth bounds the stack: each level of nesting is one JavaScript call. Width
costs only time, and the time is linear in the input for a given schema: the
checker walks lists and objects in a loop, finds each schema field by one scan
of the object (so an object costs fields × keys lookups, and the sender
controls only the keys), and checks an s.json() value in one pass. The
HTTP server's body-size limit is what bounds the input, as it does for any
parser.
Why: docs/design/capacity.md.
Measured with bun src/measure.ts (bend 2.0.37, bend-emit 0.3.9, bun 1.4.3,
macOS arm64, Apple M3 Max, one run each):
| Value | Schema | parse |
|---|---|---|
| flat list of 1,000,000 numbers | s.list(s.nat()) | 0.24 s |
| one object of 1,000,000 keys | 16 fields | 0.63 s |
| one object of 1,000,000 keys | s.json() | 0.48 s |
| 390 objects of 256 keys (100,000 counted) | s.json() | 0.03 s |
| the same | 256 fields | 1.0 s |
| the same | 256 fields, .strict() | 1.5 s |
A typed schema with many fields costs more than s.json(), because each field
is looked up in each object; you control the number of fields.
Development
Development uses Bun, Bend, and
bend-emit (a dev dependency), which
builds dist-core/.
sh test.sh # full gate: proofs, tests, types
# rebuild the compiled core
bunx bend-emit core/core.bend dist-core| Path | Contents |
|---|---|
| core/ | the Bend checker, its laws and their proofs |
| src/ | the TypeScript API, codec and Bend code generator |
| dist-core/ | the compiled core (committed) |
| examples/ | example projects |
| tools/ | build and verification tools |
License
MIT
