Schema
Overview
ovr’s Schema API is modeled after Zod and implements Standard Schema. Use it on its own to parse arbitrary values, or use Field and Form to turn those validations into HTML forms.
- Form generation -
Route.post(schema, ...)createsForm,Fields, andFieldhelpers from the schema. - Typed parsing -
Context.data()parses search params or multipart form data and returns typeddata. - Server-driven validation - Invalid submissions return a
urlyou can redirect to and re-render withstate={c.url}. - Streaming uploads - Mark file fields with
.stream()to validate fields first and then stream the remaining multipart parts. - Built-in guards - Schema forms automatically derive a multipart
partslimit and still use all multipart protections.
Parse
Use Schema on its own to validate an unknown input. Schema.parse safely parses the unknown into a Schema.Parse.Result.
- When valid, the result contains the type-safe
data. - When invalid, the result is a
Schema.AggregateIssuecontaining a non-emptySchema.Issuetuple. The result can be thrown directly or useresult.issuesto obtain more information about each validation issue.
import { Schema } from "ovr";
const user = Schema.object({
name: Schema.string().min(2),
age: Schema.number().int().min(13),
});
// create a type based on the schema
type User = Schema.Infer<typeof user>;
const result = user.parse({ name: "Frodo", age: 33 });
if (result.issues) {
result.issues; // [Schema.Issue, ...Schema.Issue[]] ([Error, ...Error[]])
throw result; // Schema.AggregateIssue (AggregateError)
} else {
result.data; // User
}
POST forms
Use Field to describe the expected inputs and pass the shape directly into Route.post. The route keeps the generated helpers from Route.post and adds schema-specific Fields, Field, and component(...) helpers, while Context.data() parses the multipart request with the matching types. Add .persist() to any non-sensitive field that should refill after an invalid redirect.
import { Field, Route } from "ovr";
export const signup = Route.post({ name: Field.text().min(2) }, async (c) => {
const result = await c.data();
if (result.issues) return c.redirect(result.url, 303);
// create account...
return <p>Welcome, {result.data.name}.</p>;
});
export const page = Route.get("/signup", (c) => <signup.Form state={c.url} />);
This adds a few helpers to the route:
signup.Formrenders the<form>shell and default fieldssignup.Fieldsrenders every field in the schemasignup.Fieldrenders one field bynamesignup.component(...)exposes the low-level field pieces
state is the current page URL. For Route.post(...).Form, it also encodes a same-origin _return query param on the action URL so invalid redirects can return to the page that rendered the form.
The default markup from <signup.Form state={c.url} /> looks like this:
<form
action="/_p/generated-hash?_return=%2Fsignup"
method="POST"
enctype="multipart/form-data"
>
<div>
<label for="name">name</label>
<input id="name" type="text" name="name" />
</div>
<button>Submit</button>
</form>
If you want to reuse the schema outside a route, create it up front with
Form.from(shape)and pass the resulting form intoRoute.getorRoute.post.
Input normalization
ovr also normalizes a few common HTML form quirks before validation:
- Single-value text-like inputs read blank values as missing (
undefined) during form parsing, so.optional()and.default(...)behave like you would expect from browser forms. Field.number()andField.range()coerce submitted strings into numbers, while blank submissions still count as missing instead of becoming0.Field.checkbox()reads presence as a boolean, so omitted checkboxes becomefalse.Field.file()andField.files()treat the browser’s empty placeholder file as missing, while real zero-byte named files are still preserved.
Invalid submissions
Context.data() validates the current request. When validation fails, the round trip looks like this:
Context.data()uses the same-origin_returnquery param from the form action, or the current request URL when_returnis missing.result.urlcontains the chosen page URL plus the encoded_formstate in a search param.- Render the next request with
state={c.url}.
Persisted form state
ovr stores issue metadata and any values marked with .persist() in the _form search param so the next render can:
- persist user input for fields marked with
.persist() - render the matching issue message
- set
aria-invalidandaria-describedby - autofocus the first invalid field
Only opt in non-sensitive fields. Values marked with .persist() are encoded into the URL-backed state and may also appear in browser history, analytics, server logs, and similar tooling.
Labels and legends default to the field
name. Simple CSS such astext-transform: capitalizeis often enough for the generated markup.
GET forms
Route.get can use the same schema helpers for forms that submit into the URL. Context.data() reads from URLSearchParams, and state={c.url} reads the current query params unless an encoded _form state is present. When _form exists, its persisted values take precedence so the next render can restore the invalid submission.
import { Field, Route } from "ovr";
export const search = Route.get(
"/search",
{
q: Field.search({ label: "Search" }).optional(),
minPrice: Field.number().min(0).optional(),
inStock: Field.checkbox(),
},
async (c) => {
const result = await c.data();
if (result.issues) return <search.Form state={result.url} />;
c.url; // /search?q=travel+backpack&minPrice=50&inStock=on
result.data; // { q: "travel backpack", minPrice: 50, inStock: true }
// fetch results...
return (
<>
<search.Form state={c.url} />
<h2>Results for {result.data.q || "all products"}</h2>
</>
);
},
);
For Route.get, the query itself lives in the URL. Use result.data for the parsed values. Use state={result.url} immediately after an invalid parse when you want to restore the submitted values and issues. Use state={c.url} when you want to reflect the current URL.
Streaming
To stream uploads, mark a file field with .stream(). Context.data() will validate the non-streamed fields first, then expose the current and remaining multipart parts on result.stream.
import { upload } from "./upload";
import { Field, Route } from "ovr";
const submit = Route.post(
{
date: Field.date(),
// place streamed fields last in the document so previous can be parsed
// or order with client side js before submission
license: Field.file().stream(),
},
async (c) => {
const result = await c.data();
if (result.issues) return c.redirect(result.url, 303);
const { date } = result.data; // non-streamed date field
if (result.stream) {
for await (const part of result.stream) {
if (part.name === "license") {
// stream the upload
await upload(part.body, date);
}
}
}
c.redirect("/files", 303);
},
);
Protections
Context.data()automatically sets the multipartpartslimit from the schema. Single inputs count as1, checkbox groups and multiselects count by their max cardinality, andField.files()usesInfinity.- You can still override the parser per request with
Context.data({ parts, memory, payload }). - App-wide multipart defaults can be set with
new App({ form: { memory, payload, parts } }). - Unexpected names in
FormDataor multipart requests become validation issues instead of being silently accepted. - Encoded
_formstate is capped at4kb, and each persisted value is capped at512serialized characters.