@umpire/json
@umpire/core is the TypeScript evaluation engine. @umpire/json is the contract layer on top of it.
It defines what can be expressed in a language-neutral schema, serializes TypeScript configs into that schema, and parses schemas back into running Umpire instances. If you need the same Umpire model in a server-rendered UI, a Node backend, or a future runtime port, this is the package that makes that possible.
Install
Section titled “Install”yarn add @umpire/core @umpire/jsonParsing from untrusted input
Section titled “Parsing from untrusted input”When a schema arrives from user input, localStorage, an API response, or any other source you don’t control, you need to validate it before hydrating it. Two APIs cover this — choose based on how much you want to separate those two steps.
fromJsonSafe(raw)
Section titled “fromJsonSafe(raw)”The one-call path. fromJsonSafe() validates and hydrates in a single step. It never throws — the return type is a discriminated union:
import { fromJsonSafe } from '@umpire/json'
const result = fromJsonSafe(raw)
if (!result.ok) { // result.errors: string[] console.error(result.errors) return}
// result.ok === trueconst { schema, fields, rules, validators } = resultThe success branch includes schema — the validated UmpireJsonSchema — alongside the hydrated fields, rules, and validators. That means you can round-trip back through toJson(result) later without re-validating.
Return type:
type FromJsonSafeResult<C> = | { ok: true; schema: UmpireJsonSchema; fields: ParsedFields; rules: ParsedRules<C>; validators: ParsedValidators } | { ok: false; errors: string[] }The generic C is the conditions type and defaults to Record<string, unknown>. Pass it explicitly when your schema uses typed conditions:
type MyConditions = { isAdmin: boolean; plan: string }
const result = fromJsonSafe<MyConditions>(raw)parseJsonSchema(raw)
Section titled “parseJsonSchema(raw)”The two-step path. parseJsonSchema() validates only — it returns the typed UmpireJsonSchema if the input is valid, or an errors array if it isn’t. Hydration happens separately, in a subsequent fromJson() call.
Use this when you need to inspect or store the validated schema before deciding whether to hydrate it:
import { parseJsonSchema, fromJson } from '@umpire/json'
const parsed = parseJsonSchema(raw)
if (!parsed.ok) { // parsed.errors: string[] console.error(parsed.errors) return}
// parsed.schema is UmpireJsonSchema — fully typed, safe to inspectconst { fields, rules, validators } = fromJson(parsed.schema)Return type:
type JsonSchemaParseResult = | { ok: true; schema: UmpireJsonSchema } | { ok: false; errors: string[] }Like fromJsonSafe(), it never throws.
Which to use
Section titled “Which to use”- Default to
fromJsonSafe(). It’s one call and its success branch already holds everything you need to runumpire()and to round-trip withtoJson(). - Reach for
parseJsonSchema()when you want to hold or examine the validated schema before hydrating — for example, to diff two versions of a schema, log it, or store it separately.
fromJson(schema)
Section titled “fromJson(schema)”fromJson() parses a trusted UmpireJsonSchema — one that has already been validated — and returns { fields, rules, validators } you can pass straight into umpire():
import { umpire } from '@umpire/core'import { fromJson } from '@umpire/json'
const { fields, rules, validators } = fromJson(schema)
const ump = umpire({ fields, rules, validators })Unlike fromJsonSafe() and parseJsonSchema(), fromJson() throws if the schema is invalid. It’s the right call when you already hold a UmpireJsonSchema — for example, the parsed.schema you get back from parseJsonSchema().
The result is composable. Hydrate most of a form from JSON and add a few hand-written rules for app-specific logic in the same umpire() call — the two sets coexist without conflict.
toJson({ fields, rules, validators, conditions })
Section titled “toJson({ fields, rules, validators, conditions })”toJson() walks a TypeScript config and writes back the parts that fit the portable contract:
import { toJson } from '@umpire/json'
const json = toJson({ fields, rules, validators, conditions })Three tiers of output:
- Hydrated rules (from
fromJson()) round-trip exactly. Their original JSON definition is preserved and written back verbatim. - Hydrated validators (from
fromJson()) round-trip exactly. Their original JSON definition is preserved and written back verbatim. - Portable hand-written rules — rules built with
namedValidators.*(),expr.*, and the portable builders — are serialized when they map cleanly to the contract. - Portable hand-written validators — validators built from
namedValidators.*()— are serialized when they map cleanly to the contract. - Everything else lands in
excluded, not dropped silently.
When the config started from a fromJson() parse, previously declared conditions and excluded entries carry forward too. If the current runtime can now serialize something that was previously excluded, toJson() replaces the old entry instead of duplicating it.
Portable validator helpers
Section titled “Portable validator helpers”namedValidators.*() are portable validator helpers that carry stable metadata across the JSON boundary:
import { check, enabledWhen } from '@umpire/core'import { namedValidators } from '@umpire/json'
enabledWhen('submit', check('email', namedValidators.email()), { reason: 'Enter a valid email address',})Plain functions, regexes, Zod schemas, and Yup schemas all work with check(). The difference is that namedValidators.*() helpers know how to serialize themselves — toJson() can write them out and fromJson() can rebuild them exactly. Plain validators land in excluded.
Built-in portable validators in version: 1:
namedValidators.email()— practical email syntaxnamedValidators.url()— absolute URL with a schemenamedValidators.matches(pattern)— regex from a serializable pattern stringnamedValidators.minLength(n)— string or array length at leastnnamedValidators.maxLength(n)— string or array length at mostnnamedValidators.min(n)— number at leastnnamedValidators.max(n)— number at mostnnamedValidators.range(min, max)— number within an inclusive rangenamedValidators.integer()— number must be an integer
The surrounding rule owns the reason string — you can pair any portable check with your own product copy.
Portable validators
Section titled “Portable validators”Top-level validators are the portable field-local validation surface. They attach to the matching field and feed Umpire’s valid / error metadata directly:
{ "validators": { "email": { "op": "email", "error": "Enter a valid email address" } }}At runtime, fromJson() turns these into umpire({ validators }) entries. The field stays structurally enabled or disabled according to rules; the validator only answers whether the current satisfied value is well-formed.
Two check shapes
Section titled “Two check shapes”In @umpire/core, check() is already doing two things depending on context: it’s a predicate factory when used inside enabledWhen() or requires(), and older configs may still use it as a standalone structural constraint. @umpire/json now keeps field-local validation separate with validators, but preserves the older check rule shape for compatibility.
Top-level "check" is the legacy standalone structural form. It still parses for compatibility, but it remains a fairness rule rather than validator metadata:
{ "type": "check", "field": "email", "op": "email" }expr.check() is the portable predicate-source form. It appears inside a predicate expression, letting one field’s availability depend on whether another field passes a portable validator:
{ "type": "enabledWhen", "field": "submit", "when": { "op": "check", "field": "email", "check": { "op": "email" } }}The modern split is:
validatorsfor field-local validation metadataexpr.check()for structural predicates that depend on another field passing a portable validator- top-level
"check"only when you need to preserve older JSON schemas
See @umpire/dsl for the full non-check expression vocabulary.
Conditions
Section titled “Conditions”Conditions are declared inputs that the consuming runtime provides at evaluation time:
{ "conditions": { "isAdmin": { "type": "boolean" }, "validPlans": { "type": "string[]" } }}Use them for external state — account tier, feature flags, auth state, server-provided option sets. They’re the correct home for anything the form itself doesn’t own.
excluded
Section titled “excluded”Some rules are too app-specific to serialize safely. When toJson() encounters one, it records it in excluded rather than dropping it:
{ "excluded": [ { "key": "fairWhen:motherboard", "type": "fairWhen", "field": "motherboard", "description": "Predicate requires runtime domain logic" } ]}excluded covers field-level slots too — field:isEmpty and field:default entries land here when they can’t be expressed as primitive values.
excluded.key gives each entry a stable identity. Later serializations can replace or remove entries by key when a runtime learns to handle a previously excluded slot natively.
excluded is informational. No runtime evaluates it automatically. Its job is to tell the next implementation: there was logic here, and you’ll need to recreate it natively.
Portable field semantics
Section titled “Portable field semantics”Field defaults stay primitive-only in the JSON contract: string, number, boolean, or null.
For isEmpty, the portable strategy names are:
'present'— default Umpire semantics (nullandundefinedare empty)'string''array''object''number''boolean'
The corresponding @umpire/core helpers (isEmptyString, isEmptyArray, isEmptyObject) round-trip cleanly through these strategy names.
Authoring for portability
Section titled “Authoring for portability”If your Umpire model needs to survive a runtime boundary, write it through the JSON vocabulary rather than plain TypeScript predicates.
The portable toolkit is:
namedValidators.*()for field-local value constraints- top-level
validatorsfor portable field-local validation expr.*from@umpire/dslfor predicate expressions insideenabledWhen,requires,disables, andfairWhen- Portable builders (
requiresJson,enabledWhenExpr,disablesExpr,fairWhenExpr) for constructing rules that carry their own JSON definition from birth
Arbitrary functions, regexes, and library validators still work at runtime — they just won’t serialize. toJson() records them in excluded and the next implementation has to recreate them natively.
See @umpire/dsl and Builders & Checks for the full authoring vocabulary.
See also
Section titled “See also”- @umpire/dsl — pure
Expr,expr.*,compileExpr, andgetExprFieldRefs - Builders & Checks — JSON-only
expr.check(),namedValidators, and portable builders - Composing with Validation — where
check()fits conceptually - check() helper — validator shapes in core
- Config-Driven UI, With Behavior — live-edit a JSON schema and watch a generic renderer respond