Form validation

Use case

You have a sign-up form (web or mobile) and need server-side validation that mirrors the UI. The browser may already block obvious mistakes, but the server is the ground truth — invalid payloads must be rejected before they reach the database.

Sapphire’s safeParse is built for this. By default it collects every issue across the whole input (abortEarly: false), giving you a flat ValidationIssue[] you can pivot into per-field error messages and ship straight to the UI.

End-to-end example

import { Sapphire, type Infer } from '@ascendance-hub/sapphire-core'

const a = new Sapphire()

const userRegistration = a.object({
  email: a.string().email({ message: 'Enter a valid email address.' }),
  password: a
    .string()
    .min(8, { message: 'Password must be at least 8 characters.' })
    .regex(/[A-Z]/, { message: 'Password must contain an uppercase letter.' })
    .regex(/[0-9]/, { message: 'Password must contain a digit.' }),
  age: a.number().int().min(13, { message: 'You must be 13 or older to register.' }),
  acceptTerms: a.type().literal(true).message({
    literal: 'You must accept the terms.',
  }),
})

type Registration = Infer<typeof userRegistration>

// --- Hypothetical Express-ish handler -------------------------------

function registerHandler(payload: unknown) {
  const result = userRegistration.safeParse(payload)

  if (!result.success) {
    // `flatten()` buckets issues into per-field message arrays plus a
    // form-level array — exactly the shape a flat form needs.
    const { fieldErrors, formErrors } = result.error.flatten()
    return { ok: false as const, fieldErrors, formErrors }
  }

  // result.data is typed as Registration
  return { ok: true as const, user: result.data }
}

// Need a custom path format (e.g. dotted `address.zip`)? Pivot the raw
// `result.error.issues` yourself — each issue carries `path`, `code`,
// `message`. For nested DTOs, `result.error.format()` returns a tree mirroring
// the schema shape with an `_errors` array at every node.

// --- A bad submission ----------------------------------------------

const sample = {
  email: 'not-an-email',
  password: 'short',
  age: 10,
  acceptTerms: false,
}

const response = registerHandler(sample)
// response.ok === false
// response.fieldErrors === {
//   email:       ['Enter a valid email address.'],
//   password:    ['Password must be at least 8 characters.',
//                 'Password must contain an uppercase letter.',
//                 'Password must contain a digit.'],
//   age:         ['You must be 13 or older to register.'],
//   acceptTerms: ['You must accept the terms.'],
// }

Step by step

  1. Define the schema with per-rule messages. a.string().min(8, { message: '...' }) attaches a message only when that specific rule fails. You can layer multiple rules on a single field — password above runs three checks (min, two regex) and every failing rule produces its own issue.
  2. Call safeParse. It never throws. The result is a discriminated union: { success: true, data } or { success: false, error }.
  3. Flatten the error. result.error.flatten() returns { fieldErrors, formErrors }fieldErrors keyed by the top-level field name, formErrors for root-level issues. No manual pivot needed for flat forms. (For dotted nested paths or a schema-shaped tree, see the note under the snippet and error.format().)
  4. Return the map to the UI. Each form field reads fieldErrors[id] and renders the message(s) inline.

Variations

Stop on the first issue (abortEarly: true)

When you’re validating a server-side webhook and don’t care about per-field UX, fail fast:

const result = userRegistration.safeParse(payload, { abortEarly: true })
// result.error.issues.length === 1 (or 0 if valid)

This is also useful when validation is part of a request body too large to walk fully.

Per-field instance-level overrides

If you want a single message for every min-length failure (e.g. for an i18n bundle), set it once on the Sapphire instance and skip the per-rule { message }:

const a = new Sapphire({
  messages: {
    min_length: (ctx) => `Field is too short (min ${ctx.min}).`,
    format: 'Invalid format.',
  },
})

See Concepts → Validation for the full 5-level resolution hierarchy.

Mapping issue paths to form field IDs

If your UI uses bracket notation (address[zip]) instead of dots, change the join:

function pathToFieldId(path: (string | number)[]) {
  return path.reduce<string>(
    (acc, seg) => (typeof seg === 'number' ? `${acc}[${seg}]` : acc ? `${acc}.${seg}` : seg),
    '',
  )
}

[!WARNING] issue.message can be an object or a string. By default it’s a string, but if you returned an object from a MessageValue (for structured i18n bundles), narrow before rendering: typeof issue.message === 'string' ? issue.message : translate(issue.message).

See also