Safe Schema

Declarative input validation with a Zod-shaped chained-method surface. Built for request-body validation, config validation, API payload validation, anywhere operators have an unknown shape they need to confirm before reading. Vendor-free; built on framework primitives. No JIT, no codegen, no chained-Promise weirdness.

Schemas are immutable — every chained check returns a new schema; the original is untouched. parse(input) throws SafeSchemaError carrying a full per-field issues array; safeParse(input) never throws and returns { ok, value?, errors? } (operator-friendly at HTTP boundaries). A value nested more than 256 levels is refused with the issue code safe-schema/value-too-deep, which parse throws and safeParse reports.

Security guarantees: prototype-pollution defense — __proto__ / constructor / prototype keys are rejected at object-shape construction AND at parse-time on object + record inputs (mirrors b.safeJson's POISONED_KEYS). Per-format defensive length caps (email 254, url 8 KiB, uuid 50, datetime 100 chars …) bound input BEFORE the regex engine runs, so a hostile payload can't ReDoS .email() / .url() / .datetime(). All format regexes are static module-level constants — no string→regex parsing on the validation path. .refine() predicates that throw turn into a regular validation issue rather than crashing the request.

Coercion is deliberately not shipped (.coerce is a footgun: "0" → 0 vs "0" → "0" ambiguity, truthy/falsy edge cases). Operators do explicit s.preprocess(fn, schema) instead.

Relationship to b.forms.validate: forms.validate carries HTML-spec concerns (checkbox coercion, select option allowlist) that don't belong on the general-purpose validator, so the two surfaces stay distinct rather than one wrapping the other.

Surface (every schema has these chained methods unless noted):

Type constructors: string() .min, .max, .length, .regex, .email, .url, .uuid, .datetime (ISO-8601), .date (YYYY-MM-DD), .ip, .ipv4, .ipv6, .nonempty, .startsWith, .endsWith, .includes, .cuid, .ulid, .base64, .trim, .toLowerCase, .toUpperCase number() .int, .min, .max, .gt, .lt, .positive, .negative, .nonnegative, .nonpositive, .finite, .safe, .multipleOf boolean() literal(v) enum_([...]) | oneOf([...]) null_(), undefined_(), any(), unknown()

Composites: object({...}) .strict, .passthrough, .pick, .omit, .extend, .partial, .required array(item) .min, .max, .length, .nonempty tuple([...]) .rest(item) union([...]) first matching option wins discriminatedUnion(key, [...]) tagged-union dispatch record(value) | record(key, value) lazy(() => schema) deferred (recursion) preprocess(fn, schema) pre-validation transform

Modifiers (any schema): .optional(), .nullable(), .default(v|fn), .catch(v|fn), .refine(fn, opts), .transform(fn), .pipe(next)

Deliberately not shipped (with structural reason): z.bigint / z.date / z.map / z.set (no JSON representation), z.nativeEnum / z.never / z.void / z.function (TypeScript-specific), z.coerce (security foot-gun — use s.preprocess), z.intersection (use .extend() for object schemas), z.brand (compile-time tag, no runtime effect), per-schema errorMap (chain .refine() with custom message instead).

Design choices: - Schemas are immutable. Chaining returns a new schema with one additional check; the original is untouched. Cheap because checks are concat'd into a small array, not deep-copied. - .optional() means "may be undefined"; .nullable() means "may be null"; .default(v) means "if undefined, substitute v"; .catch(v) means "on ANY validation failure, substitute v". These compose: optional().default(0) → "may be undefined, in which case use 0". - Objects are STRICT by default: unknown keys produce an issue. Use .passthrough() to retain unknown keys, .strict() to flip back if a parent .passthrough() set the mode. - Sync-only: no async refinements; operators await at the boundary.

b.safeSchema.SafeSchemaError #

stable0.1.0

Error class thrown by every b.safeSchema primitive on construction-time misuse (bad shape / bad enum / bad union / poisoned key) and by schema.parse(...) on validation failure. Built via b.frameworkError.defineClass and marked alwaysPermanent so it never round-trips through retry or transient-error logic. The thrown instance carries .issues — the full per-field issues array ({ path, code, message }[]) — so HTTP middleware can surface every failure in one 400 response.

var b = require("blamejs");
var s = b.safeSchema;

try {
  s.string().min(3).parse("ab");
} catch (e) {
  e instanceof s.SafeSchemaError;
  // → true
  e.issues[0].code;
  // → "string/too-short"
}

b.safeSchema.isEmail(value) #

stable0.18.31

A structural email check: exactly one @, a non-empty local part, and a domain carrying a dot with something either side. No whitespace anywhere — and "whitespace" means all 25 of the codepoints the language calls that, not the ASCII five, because an address padded with U+00A0 is not the address that was screened.

Length is NOT checked here; .email() caps it at the RFC 5321 forward-path limit first. RFC 5322 in full is a fool's errand — chain .refine() when more is needed.

b.safeSchema.isEmail("ada@example.com");                         // → true
b.safeSchema.isEmail("ada@example");                             // → false

b.safeSchema.isUrl(value) #

stable0.18.31

A URL as far as this cares: an RFC 3986 scheme, ://, and a non-empty authority-and-onwards with no whitespace in it. It does NOT parse the URL — b.safeUrl.parse does that, and applies a protocol allow-list while it is there.

b.safeSchema.isUrl("https://example.com/a");                     // → true
b.safeSchema.isUrl("example.com");                               // → false

b.safeSchema.isUuid(value) #

stable0.18.31

RFC 9562 §4 textual form — 8-4-4-4-12 hexadecimal characters — with the version nibble fixed to 1-5 and the variant nibble to the RFC 9562 §4.1 range. This is a shape check rather than a decode, so a version 6, 7 or 8 id is deliberately outside it: a schema that accepted those would be saying something about ordering guarantees it has not checked.

b.safeSchema.isUuid("123e4567-e89b-12d3-a456-426614174000");     // → true
b.safeSchema.isUuid("123e4567e89b12d3a456426614174000");         // → false

b.safeSchema.isDate(value) #

stable0.18.31

Is value exactly YYYY-MM-DD? Shape only — a month of 13 passes here and fails whoever checks the calendar. The grammar is b.time.readDate, so this and every other date reader in the framework agree by construction.

b.safeSchema.isDate("2026-08-16");                               // → true
b.safeSchema.isDate("2026-8-16");                                // → false

b.safeSchema.isDatetime(value) #

stable0.18.31

Is value an ISO 8601 date-time with an upper-case T and a MANDATORY timezone (Z or ±HH:MM)? Fractional seconds are optional. The timezone is required because a stored timestamp with no zone is a timestamp two systems will read differently.

b.safeSchema.isDatetime("2026-08-16T12:34:56Z");                 // → true
b.safeSchema.isDatetime("2026-08-16T12:34:56");                  // → false

b.safeSchema.isCuid(value) #

stable0.18.31

Is value a CUID — 25 lower-case base-36 characters starting with c? Common in TypeScript ecosystems; both v1 and v2 have this shape.

b.safeSchema.isCuid("c" + "a".repeat(24));                       // → true

b.safeSchema.isUlid(value) #

stable0.18.31

Is value a ULID — 26 characters of Crockford base32, which omits I, L, O and U so a transcribed id cannot be misread? Upper case only, and time-sortable by construction.

b.safeSchema.isUlid("01ARZ3NDEKTSV4RRFFQ69G5FAV");               // → true
b.safeSchema.isUlid("01ARZ3NDEKTSV4RRFFQ69G5FAI");               // → false

b.safeSchema.isBase64(value) #

stable0.18.31

Is value CANONICAL standard base64 — whole four-character groups, with a final group of two or three characters padded out to four? Base64url is refused; chain .regex(...) for that. b.safeBuffer.isBase64 is the looser sibling that checks the alphabet without requiring a canonical length.

b.safeSchema.isBase64("aGVsbG8=");                               // → true
b.safeSchema.isBase64("aGVsbG8");                                // → false

b.safeSchema.isIpv4(value) #

stable0.18.31

Is value a dotted-quad IPv4 address in canonical form? A leading zero on an octet is refused: 010.1.1.1 is read as octal by some resolvers and as decimal by others, and an address two systems disagree about is an access-control decision two systems disagree about.

b.safeSchema.isIpv4("192.168.1.1");                              // → true
b.safeSchema.isIpv4("010.1.1.1");                                // → false

b.safeSchema.isIpv6(value) #

stable0.18.31

Is value an IPv6 address? The algorithmic check in b.safeJson.formats, which handles every compressed :: form, the IPv4-mapped tail, and the group-count bounds a structural screen gets wrong. A zone id (fe80::1%eth0) is refused as non-portable — it names an interface on one host, so it does not travel.

b.safeSchema.isIpv6("2001:db8::1");                              // → true
b.safeSchema.isIpv6("fe80::1%eth0");                             // → false

b.safeSchema.string() #

stable0.1.0

Construct a string-typed schema. Chain .min, .max, .length, .regex, .email, .url, .uuid, .datetime, .date, .ipv4, .ipv6, .ip, .cuid, .ulid, .base64, .startsWith, .endsWith, .includes, .nonempty, .trim, .toLowerCase, .toUpperCase to add checks and coercions. Each chained call returns a new immutable schema.

The named-format methods (.email / .url / .uuid / etc.) apply a defensive length cap BEFORE running the regex so a hostile payload cannot drive the regex engine with an arbitrarily long string.

.regex(re, msg?) runs re against every value the schema validates. When the schema is built it throws SafeSchemaError with safe-schema/bad-regex when re is not a RegExp, safe-schema/unsafe-pattern when b.guardRegex.assertSafe refuses it, and safe-schema/stateful-pattern when it carries the g or y flag.

var b = require("blamejs");
var s = b.safeSchema;

var name = s.string().min(1).max(80);
name.parse("alice");
// → "alice"

var email = s.string().trim().toLowerCase().email();
email.parse("  Alice@Example.COM  ");
// → "alice@example.com"

var safe = s.string().min(3).safeParse("ab");
safe.ok;
// → false
safe.errors[0].code;
// → "string/too-short"

b.safeSchema.number() #

stable0.1.0

Construct a number-typed schema. Rejects NaN at the type check. Chain .int, .min, .max, .gt, .lt, .positive, .negative, .nonnegative, .nonpositive, .finite, .safe, .multipleOf to bound the value. .safe() enforces the Number.isSafeInteger range — important for IDs that round-trip through JSON (no BigInt support) and need to survive without precision loss.

var b = require("blamejs");
var s = b.safeSchema;

var age = s.number().int().min(0).max(150);
age.parse(30);
// → 30

try { age.parse(200); }
catch (e) { e.issues[0].code; }
// → "number/too-large"

var price = s.number().finite().multipleOf(0.01);
price.parse(19.99);
// → 19.99

b.safeSchema.boolean() #

stable0.1.0

Construct a boolean-typed schema. Strict typeof === "boolean" check — does not coerce truthy/falsy values. To accept the strings "true" / "false" from query parameters, wrap in s.preprocess(fn, s.boolean()).

var b = require("blamejs");
var s = b.safeSchema;

s.boolean().parse(true);
// → true

try { s.boolean().parse("true"); }
catch (e) { e.issues[0].code; }
// → "type"

b.safeSchema.literal(expected) #

stable0.1.0

Construct a schema that accepts exactly one specific value (compared via ===). Useful as the discriminator on tagged unions — see s.discriminatedUnion.

var b = require("blamejs");
var s = b.safeSchema;

var version = s.literal("v1");
version.parse("v1");
// → "v1"

try { version.parse("v2"); }
catch (e) { e.issues[0].code; }
// → "literal"

b.safeSchema.enum_(values) #

stable0.1.0

Construct a schema that accepts any value from the given non-empty array (compared via Set.has). Also exported as b.safeSchema.oneOf because enum is a reserved word in some tooling. Throws SafeSchemaError (safe-schema/bad-enum) when values is not a non-empty array.

var b = require("blamejs");
var s = b.safeSchema;

var role = s.enum_(["admin", "editor", "viewer"]);
role.parse("editor");
// → "editor"

try { role.parse("guest"); }
catch (e) { e.issues[0].code; }
// → "enum"

// oneOf is the same primitive under a friendlier name.
var same = s.oneOf(["a", "b"]);
same.parse("a");
// → "a"

b.safeSchema.null_() #

stable0.1.0

Construct a schema that accepts only null. Trailing-underscore name because null is a reserved word. Useful inside unions (e.g. s.union([s.string(), s.null_()])), though s.string().nullable() is the more common idiom.

var b = require("blamejs");
var s = b.safeSchema;

s.null_().parse(null);
// → null

try { s.null_().parse(0); }
catch (e) { e.issues[0].code; }
// → "type"

b.safeSchema.undefined_() #

stable0.1.0

Construct a schema that accepts only undefined. Trailing- underscore name because undefined shadows poorly. The schema is implicitly optional — parse(undefined) succeeds.

var b = require("blamejs");
var s = b.safeSchema;

s.undefined_().parse(undefined);
// → undefined

try { s.undefined_().parse(null); }
catch (e) { e.issues[0].code; }
// → "type"

b.safeSchema.any() #

stable0.1.0

Construct a schema that accepts any value, including null and undefined. Useful as a placeholder while iterating on a schema shape, or inside s.record(s.any()) when the operator wants the keys validated but not the values.

var b = require("blamejs");
var s = b.safeSchema;

s.any().parse({ anything: "goes" });
// → { anything: "goes" }

s.any().parse(null);
// → null

b.safeSchema.unknown() #

stable0.1.0

Alias for b.safeSchema.any. Some operators prefer the spelling unknown to signal "we accept anything but expect downstream code to narrow the type". Behavior is identical.

var b = require("blamejs");
var s = b.safeSchema;

s.unknown().parse({ raw: 1 });
// → { raw: 1 }

b.safeSchema.object(shape) #

stable0.1.0

Construct an object schema from a { key: schema } shape map. Strict by default — unknown keys produce an object/unknown-key issue. Chain .passthrough() to retain extras, .strict() to flip back, .pick, .omit, .extend, .partial, .required to derive related shapes.

Prototype-pollution defense — __proto__ / constructor / prototype keys are rejected at shape-construction time AND at parse time regardless of mode (.passthrough() does NOT permit them). Throws SafeSchemaError (safe-schema/poisoned-shape-key / safe-schema/bad-shape) on invalid input.

var b = require("blamejs");
var s = b.safeSchema;

var user = s.object({
  email: s.string().email(),
  age:   s.number().int().min(0).max(150),
});

user.parse({ email: "alice@example.com", age: 30 });
// → { email: "alice@example.com", age: 30 }

// Unknown keys rejected by default.
try { user.parse({ email: "a@b.com", age: 30, extra: 1 }); }
catch (e) { e.issues[0].code; }
// → "object/unknown-key"

// Prototype-pollution attempt rejected even with passthrough.
// A hostile __proto__ only becomes an own key through JSON input;
// object-literal `__proto__:` sets the prototype instead.
var loose = user.passthrough();
var hostile = JSON.parse('{ "email": "a@b.com", "age": 30, "__proto__": { "admin": true } }');
var report = loose.safeParse(hostile);
report.ok;
// → false
report.errors[0].code;
// → "object/poisoned-key"

b.safeSchema.array(itemSchema) #

stable0.1.0

Construct an array schema where every element is validated against itemSchema. Chain .min, .max, .length, .nonempty to bound the length. Issues from individual items carry their index in the path (e.g. [3]). Throws SafeSchemaError (safe-schema/bad-item) when the item argument is not a schema.

var b = require("blamejs");
var s = b.safeSchema;

var tags = s.array(s.string().min(1)).max(10);
tags.parse(["alpha", "beta"]);
// → ["alpha", "beta"]

var report = tags.safeParse(["ok", "", "also-ok"]);
report.ok;
// → false
report.errors[0].path;
// → [1]

b.safeSchema.tuple(items) #

stable0.1.0

Construct a fixed-length heterogeneous array schema. items is a non-empty array of schemas, one per slot. Chain .rest(item) to allow a variadic tail (common for [verb, ...args] / [event, payload, ...metadata] shapes). Throws SafeSchemaError (safe-schema/bad-tuple) on invalid input.

var b = require("blamejs");
var s = b.safeSchema;

var pair = s.tuple([s.string(), s.number()]);
pair.parse(["count", 42]);
// → ["count", 42]

// Variadic tail via .rest()
var event = s.tuple([s.string()]).rest(s.number());
event.parse(["sum", 1, 2, 3]);
// → ["sum", 1, 2, 3]

b.safeSchema.union(options) #

stable0.1.0

Construct a schema that accepts a value matching ANY of the given option schemas. First match wins. When no option matches, the failure carries a parent-level union issue summarizing the miss, followed by the first 100 issues the options reported and, when there were more, one union/issues-omitted issue giving the count left out. A union evaluated inside another union's option checks each of its options' literal, enum, string, number and boolean keys first, stops an option at its first failure, and reports that failure, for every object, array, tuple and record whose schema holds no refine, transform or preprocess callback, no default or catch factory, and no lazy that has not been resolved yet; the others are validated field by field in order, as at the top level. While a union or a lazy target is being validated, a schema that has already failed for a value is not run again for that value: the earlier failure is reported at the new position, and each of its issues counts toward the evaluation limit. A schema that has already accepted a value at the same position in the input returns that result again. A result is reused only when no refine, transform or preprocess callback, lazy getter, or default or catch factory ran while it was computed or has run since. A recursive schema whose options descend into the same child costs the size of the value, whether or not its lazy references have been resolved by an earlier parse, and a transform or default factory still runs for every value it produces. For tagged-variant shapes prefer s.discriminatedUnion — it dispatches in O(1) on the tag and produces clearer error messages.

var b = require("blamejs");
var s = b.safeSchema;

var idOrName = s.union([s.number().int().positive(), s.string().min(1)]);
idOrName.parse(42);
// → 42
idOrName.parse("alice");
// → "alice"

try { idOrName.parse(true); }
catch (e) { e.issues[0].code; }
// → "union"

b.safeSchema.record(a, b) #

stable0.1.0

Construct a schema for an object whose KEYS are arbitrary strings and whose VALUES match a given schema. Two call shapes: record(valueSchema) accepts any string key; record(keySchema, valueSchema) validates both keys and values. Prototype-pollution defense — __proto__ / constructor / prototype keys are rejected at parse time, mirroring s.object. Throws SafeSchemaError on invalid arguments.

var bjs = require("blamejs");
var s = bjs.safeSchema;

var counts = s.record(s.number().int().nonnegative());
counts.parse({ apples: 3, pears: 0 });
// → { apples: 3, pears: 0 }

// Validate keys too: only ULIDs allowed.
var byId = s.record(s.string().ulid(), s.string());
byId.parse({ "01HF5Z6Q9P8R7S4T3V2W1X0Y9Z": "alice" });
// → { "01HF5Z6Q9P8R7S4T3V2W1X0Y9Z": "alice" }

b.safeSchema.discriminatedUnion(discriminator, options) #

stable0.1.0

Construct a tagged-union schema. discriminator names a key present on every option as a s.literal(...) schema; the validator dispatches in O(1) on that key's value rather than trying every option in turn (faster + clearer error messages than s.union). Every option must be an object schema whose shape carries a literal at the discriminator key. The discriminator name itself cannot be __proto__ / constructor / prototype. Throws SafeSchemaError on invalid input.

var b = require("blamejs");
var s = b.safeSchema;

var event = s.discriminatedUnion("kind", [
  s.object({ kind: s.literal("created"), at: s.string().datetime() }),
  s.object({ kind: s.literal("deleted"), reason: s.string() }),
]);

event.parse({ kind: "created", at: "2026-01-01T00:00:00Z" });
// → { kind: "created", at: "2026-01-01T00:00:00Z" }

var report = event.safeParse({ kind: "unknown" });
report.ok;
// → false
report.errors[0].code;
// → "discriminated-union/no-match"

b.safeSchema.preprocess(fn, inner) #

stable0.1.0

Wrap a schema with a transform that runs BEFORE validation. Common at HTTP boundaries where query strings arrive as strings but the operator wants a number / boolean schema downstream. Errors thrown by fn propagate as a preprocess issue at the parent path rather than crashing the parse. Throws SafeSchemaError (safe-schema/bad-preprocess) when args are the wrong shape.

Prefer s.preprocess over a hypothetical .coerce because coercion ambiguity ("0" → 0 vs "0" → "0") is a security foot-gun; s.preprocess makes the conversion explicit at the call site.

var b = require("blamejs");
var s = b.safeSchema;

var port = s.preprocess(
  function (v) { return Number(v); },
  s.number().int().min(1).max(65535)
);

port.parse("8080");
// → 8080

var report = port.safeParse("not-a-port");
report.ok;
// → false

b.safeSchema.lazy(getter) #

stable0.1.0

Defer schema construction until first parse, enabling recursive shapes (comment threads, file-tree nodes, AST nodes). getter is a no-arg function that returns the schema; it's called once on the first parse and cached. The returned schema can reference its enclosing variable, breaking the chicken-and-egg cycle. Throws SafeSchemaError (safe-schema/bad-lazy) when getter is not a function.

var b = require("blamejs");
var s = b.safeSchema;

var commentSchema = s.object({
  id:       s.string(),
  replies:  s.array(s.lazy(function () { return commentSchema; })),
});

var input = { id: "a", replies: [{ id: "b", replies: [] }] };
commentSchema.parse(input);
// → { id: "a", replies: [{ id: "b", replies: [] }] }

b.safeSchema.optional(inner) #

stable0.1.0

Composition-style alias for inner.optional(). Returns a schema that accepts undefined in addition to whatever inner accepts. Equivalent to chaining .optional() on the schema; provided for operators who prefer composition over chaining (e.g. mapping over a list of schemas).

var b = require("blamejs");
var s = b.safeSchema;

var maybeName = s.optional(s.string().min(1));
maybeName.parse(undefined);
// → undefined
maybeName.parse("alice");
// → "alice"

b.safeSchema.nullable(inner) #

stable0.1.0

Composition-style alias for inner.nullable(). Returns a schema that accepts null in addition to whatever inner accepts. Compose with s.optional for "may be undefined OR null".

var b = require("blamejs");
var s = b.safeSchema;

var maybeAge = s.nullable(s.number().int().min(0));
maybeAge.parse(null);
// → null
maybeAge.parse(30);
// → 30

Last updated 2026-10-12T14:21:56.879Z by seeder.