Schema
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.
Parse
Schema.parse safely parses an unknown into a Schema.Parse.Result.
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
}
- When invalid, the result is a
Schema.AggregateIssuecontaining a non-emptyissuestuple. - When valid, the result contains the type-safe
data.
POST forms
Use Field to describe the expected inputs for a POST route. Context.data() parses the multipart request with the matching types.
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} />);
signup.Form renders the <form> and the fields inside by default.
<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>
Labels and legends default to the field
name. Simple CSS such astext-transform: capitalizeis often enough for the generated markup. Alternatively, alabelprop can be passed into the field during creation for a custom label:Field.text({ label: "Full Name" }).
signup.Fieldsrenders all the fields without the outer<form>shellsignup.Fieldrenders one field byname(each key in the shape)signup.componentexposes the low-level field components for more customization, for example if you need to change the position of the label or input.
The state prop for each component takes the current page c.url. The URL contains a same-origin _return query param on the action URL so invalid redirects can return to the page that rendered the form.
If you want to reuse the schema outside of a route, create it 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 during form parsing, so optional fields are omitted from the parsed output and
.default(...)still applies like you would expect. 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.
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.
GET forms
Route.get can use the same schema helpers for GET forms that send the value as search params. Context.data() reads the _form to create a URL, or the current URL can also be used to persist the state.
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} />;
result.data; // { q: "travel backpack", minPrice: 50, inStock: true }
// fetch results...
return (
<>
<search.Form
state={
c.url // /search?q=travel+backpack&minPrice=50&inStock=on
}
/>
<h2>Results for {result.data.q || "all products"}</h2>
</>
);
},
);
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 adjust the 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.