Skip to content

Client ​

Overview ​

SchemaBuilder::toJsonSchema() exports the PHP-side validation rules as a standard JSON Schema (draft 2020-12) object. Any JSON Schema-compatible validator can consume it directly on the client side.

@uuki/schemable-validator-client also ships built-in adapters that convert the JSON Schema into native Zod or Valibot schemas, enabling type inference and framework integration beyond what plain JSON Schema validators provide.


Basic usage ​

Because the output is standard JSON Schema, you can validate with any conformant library. The example below uses AJV — no adapter required.

pnpm add ajv ajv-formats
ts
import Ajv from 'ajv'
import addFormats from 'ajv-formats'

const ajv = new Ajv()
addFormats(ajv)

// jsonSchema is the object returned by SchemaBuilder::toJsonSchema() on the PHP side
const jsonSchema = await fetch('/api/schema/contact').then(r => r.json())
const validate = ajv.compile(jsonSchema)

const formEl = document.querySelector<HTMLFormElement>('#my-form')!

formEl.addEventListener('submit', (e) => {
  e.preventDefault()

  const data = Object.fromEntries(new FormData(formEl))
  const valid = validate(data)

  if (valid) {
    console.log(data)
  } else {
    const errors: Record<string, string> = {}
    for (const err of validate.errors ?? []) {
      const field = (err.instancePath.slice(1) || err.params?.missingProperty) as string
      if (field) errors[field] = err.message ?? 'Invalid'
    }
    console.log(errors)
    // { email: 'must match format "email"' }
  }
})

Adapter ​

When you need TypeScript type inference, reactive form integration, or custom refiners, use the built-in adapters. The recommended entry point is the sv() fluent builder.

The example below uses a minimal inline schema. In real usage jsonSchema comes from the server.

ts
import { sv } from '@uuki/schemable-validator-client/zod'

const jsonSchema = {
  $schema: 'https://json-schema.org/draft/2020-12/schema',
  type: 'object',
  properties: {
    name:  { type: 'string', minLength: 1 },
    email: { type: 'string', format: 'email' },
  },
  required: ['name', 'email'],
} as const

const schema = sv(jsonSchema).build()

const formEl = document.querySelector<HTMLFormElement>('#my-form')!

formEl.addEventListener('submit', (e) => {
  e.preventDefault()

  const data = Object.fromEntries(new FormData(formEl))
  const result = schema.safeParse(data)

  if (result.success) {
    console.log(result.data)
    // { name: 'Alice', email: 'alice@example.com' }
  } else {
    const errors: Record<string, string> = {}
    for (const issue of result.error.issues) {
      errors[String(issue.path[0])] = issue.message
    }
    console.log(errors)
    // { email: 'Invalid email' }
  }
})
ts
import { sv } from '@uuki/schemable-validator-client/valibot'
import * as v from 'valibot'

const jsonSchema = {
  $schema: 'https://json-schema.org/draft/2020-12/schema',
  type: 'object',
  properties: {
    name:  { type: 'string', minLength: 1 },
    email: { type: 'string', format: 'email' },
  },
  required: ['name', 'email'],
} as const

const schema = sv(jsonSchema).build()

const formEl = document.querySelector<HTMLFormElement>('#my-form')!

formEl.addEventListener('submit', (e) => {
  e.preventDefault()

  const data = Object.fromEntries(new FormData(formEl))
  const result = v.safeParse(schema, data)

  if (result.success) {
    console.log(result.output)
    // { name: 'Alice', email: 'alice@example.com' }
  } else {
    const errors: Record<string, string> = {}
    for (const issue of result.issues) {
      errors[String(issue.path?.[0]?.key ?? '')] = issue.message
    }
    console.log(errors)
    // { email: 'Invalid email' }
  }
})

Custom mapping ​

The adapter converts most PHP rules automatically. Use these builder methods and options to handle the rest.

Handling unsupported rules (onUnknown) ​

When the adapter encounters a field it cannot map, the onUnknown option controls what happens.

ValueBehaviour
'warn'console.warn and fall back to unknown
'throw'throw an Error immediately
(key, field) => Schemacall the function and use the returned schema
(default)'warn' in development, 'throw' in production

The default resolves from process.env.NODE_ENV, which Vite and webpack replace with a string literal at build time — no extra configuration needed.

ts
import { sv } from '@uuki/schemable-validator-client/zod'
import { z } from 'zod'

const schema = sv(jsonSchema)
  .onUnknown((key, field) => {
    if (field.format === 'hostname') {
      return z.string().regex(/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/)
    }
    throw new Error(`[zod] unsupported field "${key}": ${JSON.stringify(field)}`)
  })
  .build()
ts
import { sv } from '@uuki/schemable-validator-client/valibot'
import * as v from 'valibot'

const schema = sv(jsonSchema)
  .onUnknown((key, field) => {
    if (field.format === 'hostname') {
      return v.pipe(v.string(), v.regex(/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/))
    }
    throw new Error(`[valibot] unsupported field "${key}": ${JSON.stringify(field)}`)
  })
  .build()

Conditional requirements (when) ​

SchemaBuilder::when() emits an x-when array. Call .when() on the builder to auto-apply every condition from the schema as a field-level required check:

ts
import { sv } from '@uuki/schemable-validator-client/zod'

// PHP: ->when('type', SV::equal('company'), ['company_name'])
const schema = sv(jsonSchema).when().build()

const result = schema.safeParse(formData)
ts
import { sv } from '@uuki/schemable-validator-client/valibot'
import * as v from 'valibot'

const schema = sv(jsonSchema).when().build()

const result = v.safeParse(schema, formData)

.when() reads every entry in x-when and enforces it with a field-level error. All operators (===, !==, >=, <=, >, <, field references) are supported.

File fields (x-unmapped-fields) ​

SV::file() fields are excluded from the JSON Schema output by the PHP side and listed in x-unmapped-fields. The adapters skip them automatically. Add them back with .extend():

ts
import { sv } from '@uuki/schemable-validator-client/zod'
import { z } from 'zod'

const schema = sv(jsonSchema)
  .extend({ avatar: z.instanceof(File).refine((f) => f.size < 5_000_000, 'Max 5 MB') })
  .build()
ts
import { sv } from '@uuki/schemable-validator-client/valibot'
import * as v from 'valibot'

const schema = sv(jsonSchema)
  .extend({ avatar: v.pipe(v.instance(File), v.maxSize(5_000_000)) })
  .build()

Acknowledging server-only fields ​

validateObject() emits a console warning when x-unmapped-fields contains field names not yet acknowledged by the caller. Pass acknowledgedServerFields to suppress the warning for fields you expect to validate only on the server:

ts
import { validateObject } from '@uuki/schemable-validator-client'

const result = validateObject(formData, schema, {
  acknowledgedServerFields: ['avatar', 'custom_check'],
})

The same option is available in the createSv() factory config for the Zod and Valibot adapters:

ts
import { createSv } from '@uuki/schemable-validator-client/zod'

const sv = createSv({
  check: true,
  acknowledgedServerFields: ['avatar'],
})

Fields listed in acknowledgedServerFields are excluded from the x-custom-fields warning during build(). When a new server-only field is added on the PHP side, the warning resurfaces until it is acknowledged — this prevents silent omissions.

Cross-field constraints (RespectRules::rule) ​

RespectRules::rule() wraps arbitrary Respect/Validation rules that have no JSON Schema mapping. Implement their client-side equivalent as a pure function and inject it with .refine():

ts
import { sv } from '@uuki/schemable-validator-client/zod'
import type { ZodRefiner } from '@uuki/schemable-validator-client/zod'
import { z } from 'zod'

// PHP: 'confirm' => RespectRules::rule(v::equals($data['password']))
const checkConfirm: ZodRefiner = (data, ctx) => {
  if (data.confirm !== data.password) {
    ctx.addIssue({
      code: 'custom',
      path: ['confirm'],
      message: 'Must match password',
    })
  }
}

const schema = sv(jsonSchema).refine(checkConfirm).build()
ts
import { sv } from '@uuki/schemable-validator-client/valibot'
import type { ValibotRefiner } from '@uuki/schemable-validator-client/valibot'

const checkConfirm: ValibotRefiner = ({ dataset, addIssue }) => {
  if (!dataset.typed) return
  const d = dataset.value as { confirm?: string; password?: string }
  if (d.confirm !== d.password) addIssue({ message: 'Must match password' })
}

const schema = sv(jsonSchema).refine(checkConfirm).build()

Runtime coverage check ​

When the schema arrives via fetch, its contents are unknown until runtime. Use checkZodSchema / checkValibotSchema to get a coverage report without throwing:

ts
import { checkZodSchema }     from '@uuki/schemable-validator-client/zod'
import { checkValibotSchema } from '@uuki/schemable-validator-client/valibot'

const jsonSchema = await fetchSchema('/api/schema/contact')

const report = checkZodSchema(jsonSchema)
// { supported: ['name', 'email'], unsupported: [{ key: 'host', reason: 'format "hostname" ...' }] }

if (report.unsupported.length) {
  console.warn('[schemable] unsupported fields:', report.unsupported)
}

Pass check: true to createSv() to run the check automatically during every build() call. Use createSv() to configure a factory once and share it across all forms:

ts
import { createSv } from '@uuki/schemable-validator-client/zod'
import type { ZodRefiner } from '@uuki/schemable-validator-client/zod'
import { z } from 'zod'

// Configure once at app startup — share across all forms
const sv = createSv({
  check: true,  // warn about unsupported fields during build()
  onUnknown: (key, field) => {
    // add per-format fallbacks here as they surface in dev
    throw new Error(`unsupported field "${key}": ${field.format ?? field.type}`)
  },
})

async function buildSchema(url: string) {
  const jsonSchema = await fetchSchema(url)
  return sv(jsonSchema).when().build()
}

Advanced validation patterns ​

Some constraints cannot be expressed in a JSON Schema at all — they depend on real-time data, user state, or business rules that live entirely on the FE. These patterns compose with the builder.

The key design principle: complex logic stays outside the builder. Validator functions are plain functions injected with .refine() / .refineAsync(). No library coupling, no builder internals.

Async field validation ​

Check a value against the server (e.g. username availability) by extending with an async schema field, or using .refineAsync() for cross-field async checks.

ts
import { sv } from '@uuki/schemable-validator-client/zod'
import type { ZodAsyncRefiner } from '@uuki/schemable-validator-client/zod'

const checkUsernameAvailable: ZodAsyncRefiner = async (data, ctx) => {
  const res = await fetch(`/api/users/check?name=${encodeURIComponent(data.username as string)}`)
  const { available } = await res.json() as { available: boolean }
  if (!available) {
    ctx.addIssue({ code: 'custom', path: ['username'], message: 'Username is already taken' })
  }
}

const jsonSchema = await fetchSchema('/api/schema/register')

// Async schemas require parseAsync
const schema = sv(jsonSchema).when().refineAsync(checkUsernameAvailable).build()
const result = await schema.safeParseAsync(formData)
ts
import { sv } from '@uuki/schemable-validator-client/valibot'
import type { ValibotAsyncRefiner } from '@uuki/schemable-validator-client/valibot'
import * as v from 'valibot'

const checkUsernameAvailable: ValibotAsyncRefiner = async ({ dataset, addIssue }) => {
  if (!dataset.typed) return
  const d = dataset.value as { username: string }
  const res = await fetch(`/api/users/check?name=${encodeURIComponent(d.username)}`)
  const { available } = await res.json() as { available: boolean }
  if (!available) addIssue({ message: 'Username is already taken' })
}

const jsonSchema = await fetchSchema('/api/schema/register')

// Async schemas require safeParseAsync
const schema = sv(jsonSchema).when().refineAsync(checkUsernameAvailable).build()
const result = await v.safeParseAsync(schema, formData)

Cross-field business rules ​

Rules like date-range ordering ("end must be after start") cannot be expressed in JSON Schema. Add them with .refine() — the function is a pure validator with no builder dependency:

ts
import { sv } from '@uuki/schemable-validator-client/zod'
import type { ZodRefiner } from '@uuki/schemable-validator-client/zod'
import { z } from 'zod'

const checkDateRange: ZodRefiner = (data, ctx) => {
  if (data.start_date && data.end_date && data.start_date >= data.end_date) {
    ctx.addIssue({
      code: 'custom',
      path: ['end_date'],
      message: 'Must be after start date',
    })
  }
}

const schema = sv(jsonSchema).when().refine(checkDateRange).build()
const result = schema.safeParse(formData)
ts
import { sv } from '@uuki/schemable-validator-client/valibot'
import type { ValibotRefiner } from '@uuki/schemable-validator-client/valibot'

const checkDateRange: ValibotRefiner = ({ dataset, addIssue }) => {
  if (!dataset.typed) return
  const d = dataset.value as { start_date?: string; end_date?: string }
  if (d.start_date && d.end_date && d.start_date >= d.end_date) {
    addIssue({ message: 'Must be after start date' })
  }
}

const schema = sv(jsonSchema).when().refine(checkDateRange).build()
const result = v.safeParse(schema, formData)

Composing all layers ​

A complete fetch-based form layering adapter conversion, x-when conditions, file field extension, and a custom business rule:

ts
import { createSv } from '@uuki/schemable-validator-client/zod'
import type { ZodRefiner } from '@uuki/schemable-validator-client/zod'
import { z } from 'zod'

// Shared factory — configured once
const sv = createSv({
  check: true,
  onUnknown: (key, field) => {
    if (field.format === 'hostname') {
      return z.string().regex(/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/)
    }
    throw new Error(`unsupported field "${key}"`)
  },
})

// External validator — pure function, no builder coupling
const checkDeliveryDate: ZodRefiner = (data, ctx) => {
  if (data.delivery_date && (data.delivery_date as string) < new Date().toISOString().slice(0, 10)) {
    ctx.addIssue({
      code: 'custom',
      path: ['delivery_date'],
      message: 'Delivery date must be today or later',
    })
  }
}

async function buildOrderSchema() {
  const jsonSchema = await fetchSchema('/api/schema/order')

  return sv(jsonSchema)
    .extend({ receipt: z.instanceof(File).optional() })  // file field from x-unmapped-fields
    .when()                                               // x-when conditional requirements
    .refine(checkDeliveryDate)                            // FE business rule
    .build()
}

Custom constraints ​

validateObject evaluates each field through a Constraint pipeline — a chain of pure functions typed as (state: FieldState) => FieldState. Each function appends to state.errors on failure and passes the state through unchanged on success. All errors are accumulated; there is no short-circuit.

Define a custom Constraint and compose it alongside the built-in rules with composeConstraints:

ts
import {
  composeConstraints, constraintsFromSchema, validateObject,
} from '@uuki/schemable-validator-client'
import type { Constraint, ObjectSchema } from '@uuki/schemable-validator-client'

// A Constraint is a pure function: FieldState → FieldState
const checkJapanesePhone: Constraint = (state) =>
  /^(0\d{9,10}|0\d{1,4}-\d{1,4}-\d{3,4})$/.test(state.value)
    ? state
    : { ...state, errors: [...state.errors, '日本の電話番号形式で入力してください'] }

// Compose: built-in string type check → custom phone format check
const phoneConstraint = composeConstraints([
  constraintsFromSchema({ type: 'string' }),
  checkJapanesePhone,
])

Apply it on top of validateObject output by merging the field result:

ts
function validateWithCustomRules(data: Record<string, string>) {
  const base = validateObject(data, schema)
  const phoneState = phoneConstraint({ value: data.phone ?? '', errors: [] })
  return {
    ...base,
    phone: {
      value: data.phone ?? '',
      is_valid: base.phone.is_valid && phoneState.errors.length === 0,
      errors:
        [...(base.phone?.errors ?? []), ...phoneState.errors].length > 0
          ? [...(base.phone?.errors ?? []), ...phoneState.errors]
          : null,
    },
  }
}

→ Full implementation sample: Examples — 4. Adding a Custom Constraint


Rule coverage ​

Coverage is measured against the 19 mappable rules in RuleMapper.php.
(SV::file() / RespectRules::rule() are excluded by the PHP side via x-unmapped-fields and are never passed to the adapter.)

PHP ruleJSON Schema fieldZodValibot
stringtype: 'string'✅✅
integertype: 'integer'✅✅
numbertype: 'number'✅✅
booleantype: 'boolean'✅✅
.nullable()type: ['X', 'null']✅✅
lengthminLength / maxLength✅✅
min / maxminimum / maximum✅✅
emailformat: 'email'✅✅
urlformat: 'uri'✅✅
dateformat: 'date'✅✅
dateTimeformat: 'date-time'✅✅
timeformat: 'time'✅✅
uuidformat: 'uuid'✅✅
ipv4format: 'ipv4'✅✅
ipv6format: 'ipv6'✅✅
patternpattern✅✅
slugpattern: '^[a-z0-9]+...'✅✅
inenum✅✅
ArraySchematype: 'array' + items / minItems / maxItems✅✅
domainformat: 'hostname'⚠️⚠️

18 / 19 — 94.7% for both adapters.

hostname has no built-in equivalent in Zod or Valibot. Use onUnknown to supply a custom mapping (see above).