[ Language & values ]
@sdxc/validate
Standard Schema validation utilities
- Installs with
- @sdxc/result@sdxc/types
- Depends on
@standard-schema/spec- Used by
- uptime, auth-saas, reader, blog, books
- Source
- packages/validate
Standard Schema validation for form submissions, request bodies and plain objects, answering a Result instead of throwing.
Installation
npm add @sdxc/validate
Bring a schema library that implements Standard Schema, such as remix's remix/data-schema, Valibot or ArkType. @sdxc/result installs with this package and supplies isFailure, success and failure.
Usage
Validate a plain object
import { isFailure } from "@sdxc/result";
import { validate } from "@sdxc/validate";
import * as s from "remix/data-schema";
import { email, minLength } from "remix/data-schema/checks";
let schema = s.object({
email: s.string().pipe(email()),
name: s.string().pipe(minLength(2)),
});
let result = await validate({ email: "ada@example.com", name: "Ada" }, schema);
if (isFailure(result)) console.error(result.error.issues);
else console.log(result.data.email); // typed from the schema
Validate a form submission
FormData and URLSearchParams flatten to an object first: a name submitted once becomes a value, and a name submitted several times becomes an array.
let form = new FormData();
form.append("email", "ada@example.com");
form.append("tags", "typescript");
form.append("tags", "validation");
let result = await validate(form, s.object({ email: s.string(), tags: s.array(s.string()) }));
// result.data: { email: "ada@example.com", tags: ["typescript", "validation"] }
Validate a request
A Request is read by its Content-Type, so one call covers JSON, multipart and URL-encoded submissions:
async function handler(request: Request) {
let result = await validate(request, schema);
if (isFailure(result)) {
return Response.json({ errors: result.error.issues }, { status: 400 });
}
return Response.json({ user: result.data });
}
Read the failure
Every issue carries the path it failed at, so a refusal reports field by field:
if (isFailure(result)) {
for (let issue of result.error.issues) console.log(issue.path, issue.message);
// ["email"] Expected valid email
// ["name"] Expected at least 2 characters
}
API
validate<Schema>(input, schema): Promise<Result<StandardSchemaV1.InferOutput<Schema>, ValidationError>>
Runs input through any Standard Schema V1 schema and resolves to the schema's output type or a ValidationError; the success type is inferred from the schema. input is one of:
FormDataorURLSearchParams: flattened to an object, a repeated name becoming an array. A schema that validates the raw source (one that reportsExpected FormData or URLSearchParams, such asremix/data-schema/form-data'sobject()) receives the originalFormDataorURLSearchParamsinstead.Request: the body is read as JSON forapplication/jsonand any+jsonmedia type, asFormDataformultipart/form-data, and asURLSearchParamsforapplication/x-www-form-urlencoded. A malformed JSON body refuses withInvalid JSON in request body, and any other content type refuses withUnsupported content-type: ….A plain object or any JSON value, handed to the schema as it is.
ValidationError
The Error a refusal carries. message is "Validation Error" and issues is the schema's own StandardSchemaV1.Issue[], each a message plus an optional path.
new ValidationError(issues: readonly StandardSchemaV1.Issue[])
Builds a refusal from your own issues, so a rule checked outside the schema fails the same way the schema does.
Pattern: One Schema Behind Every Entry Point
A schema is a value, so a JSON API, an HTML form and a query string can share it. Validate a query string by passing the URL's searchParams:
import { isFailure } from "@sdxc/result";
import { validate } from "@sdxc/validate";
import * as s from "remix/data-schema";
import { email, minLength } from "remix/data-schema/checks";
import * as coerce from "remix/data-schema/coerce";
let signup = s.object({
email: s.string().pipe(email()),
name: s.string().pipe(minLength(2)),
});
let search = s.object({
q: s.defaulted(s.string(), ""),
page: s.defaulted(coerce.number(), 1),
});
async function create(request: Request) {
let result = await validate(request, signup); // JSON or form body
if (isFailure(result)) return Response.json({ errors: result.error.issues }, { status: 400 });
return Response.json({ user: result.data }, { status: 201 });
}
async function list(request: Request) {
let result = await validate(new URL(request.url).searchParams, search);
if (isFailure(result)) return Response.json({ errors: result.error.issues }, { status: 400 });
return Response.json({ query: result.data.q, page: result.data.page });
}
Pattern: Normalizing While Validating
Everything a form submits is a string. Coerce and transform in the schema so the handler receives the shape it wants:
import { validate } from "@sdxc/validate";
import * as s from "remix/data-schema";
import { email } from "remix/data-schema/checks";
import * as coerce from "remix/data-schema/coerce";
let schema = s.object({
email: s
.string()
.pipe(email())
.transform((value) => value.toLowerCase()),
age: coerce.number(),
newsletter: s.defaulted(coerce.boolean(), false),
tags: s.string().transform((value) => value.split(",")),
});
let form = new FormData();
form.append("email", "ADA@EXAMPLE.COM");
form.append("age", "25");
form.append("tags", "typescript,validation");
let result = await validate(form, schema);
// result.data: { email: "ada@example.com", age: 25, newsletter: false, tags: ["typescript", "validation"] }
Pattern: Uploads With a Form-Data Schema
Schemas from remix/data-schema/form-data read the FormData itself, which is what file fields need. validate hands them the original submission:
import { isFailure } from "@sdxc/result";
import { validate } from "@sdxc/validate";
import * as s from "remix/data-schema";
import * as f from "remix/data-schema/form-data";
let upload = f.object({
title: f.field(s.string()),
attachments: f.files(s.array(s.instanceof_(File))),
});
async function handler(request: Request) {
let result = await validate(request, upload);
if (isFailure(result)) return Response.json({ errors: result.error.issues }, { status: 400 });
for (let file of result.data.attachments) console.log(file.name, file.size);
return new Response(null, { status: 204 });
}
Pattern: Refusing On a Business Rule
Some rules need a database or another service. Fail them with your own ValidationError so callers handle one error type:
import type { Result } from "@sdxc/result";
import { failure, isFailure, success } from "@sdxc/result";
import { ValidationError, validate } from "@sdxc/validate";
async function register(request: Request): Promise<Result<{ email: string }, ValidationError>> {
let result = await validate(request, signup);
if (isFailure(result)) return result;
if (await emailTaken(result.data.email)) {
return failure(new ValidationError([{ message: "That email is taken", path: ["email"] }]));
}
return success({ email: result.data.email });
}
Leave path out for a refusal that belongs to no single field.
Pattern: Rendering a Refused Submission
ValidationError.issues matches the issue shape of the Form in @sdxc/ui. Pass it straight through: each field shows its own messages by name, marks itself invalid, and the first invalid field takes focus. Issues without a path render at the top of the form.
import type { Handle } from "remix/component";
import { isFailure } from "@sdxc/result";
import { Button, Form, TextField } from "@sdxc/ui";
import { validate } from "@sdxc/validate";
function SignupForm(handle: Handle<{ issues?: ReadonlyArray<Form.Issue> }>) {
return () => (
<Form method="post" issues={handle.props.issues}>
<TextField label="Email" name="email" type="email" required />
<TextField label="Name" name="name" required />
<Button type="submit">Sign up</Button>
</Form>
);
}
let result = await validate(request, signup);
if (isFailure(result)) return <SignupForm issues={result.error.issues} />;
Pattern: Custom Messages
The messages in issues are the schema's. With remix/data-schema, a check is a plain object, so spread it and override message; .refine() takes its message as the second argument:
import * as s from "remix/data-schema";
import { email, minLength } from "remix/data-schema/checks";
let schema = s.object({
email: s.string().pipe({ ...email(), message: "Please enter a valid email address" }),
password: s
.string()
.pipe({ ...minLength(8), message: "Password must be at least 8 characters" })
.refine((value) => /[A-Z]/.test(value), "Password must contain an uppercase letter"),
});