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
- 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 —passwordabove runs three checks (min, tworegex) and every failing rule produces its own issue. - Call
safeParse. It never throws. The result is a discriminated union:{ success: true, data }or{ success: false, error }. - Flatten the error.
result.error.flatten()returns{ fieldErrors, formErrors }—fieldErrorskeyed by the top-level field name,formErrorsfor 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 anderror.format().) - 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.messagecan be an object or a string. By default it’s a string, but if you returned an object from aMessageValue(for structured i18n bundles), narrow before rendering:typeof issue.message === 'string' ? issue.message : translate(issue.message).
See also
- Concepts → Validation —
parse/safeParse,IssueCodetable, message resolution. - Concepts → Config — instance options (
abortEarly,stripUnknown,messages). - Recipes → Custom error messages — i18n and branded messages.