Interop
Standard Schema
Luq implements Standard Schema v1. Anything that accepts a Standard Schema — tRPC, TanStack Form, Hono, t3-env, react-hook-form — accepts a Luq validator wherever it accepts a zod schema.
It is a subpath, not part of build().
Carrying ~standard on every validator would
charge every caller for a seam most of them never cross, so you import it
and pay for it when you use it.
One import
import { Builder } from "@maroonedog/luq";
import { requiredPlugin } from "@maroonedog/luq/plugins/required";
import { stringMinPlugin } from "@maroonedog/luq/plugins/stringMin";
import { toStandardSchema } from "@maroonedog/luq/standard-schema";
type Account = { handle: string };
export const account = toStandardSchema(
Builder()
.use(requiredPlugin)
.use(stringMinPlugin)
.for<Account>()
.v("handle", (b) => b.string.required().min(2))
.build()
);
const outcome = account["~standard"].validate({ handle: "j" });
if (outcome.issues === undefined) {
console.log(outcome.value.handle);
} else {
for (const issue of outcome.issues) {
console.log(issue.message, issue.path);
}
}
What comes back is still the validator:
validate, parse,
pick and pickAll
are all still there for the rest of the application.
// toStandardSchema ADDS ~standard and takes nothing away, so the same value is
// still the validator. Nothing has to be given up to get the other face.
account.pick("handle").validate("j");
account.parse({ handle: "ada" }); Three decisions the spec leaves open
validate calls parse(), not validate()
The spec's success result is { value: Output }, and Output is the value AFTER validation — so a transform has to be applied, and only parse() applies it. This is also why a normalize reaches the consumer: the tidied value is the one written back.
It collects every issue
The library default is abortEarly: true; this seam turns it off. What consumes it is usually a form, and returning one issue at a time produces a UI where fixing an error reveals the next one. Callers who want the fast path use the validator directly.
InferInput is the type you wrote
It is the type given to .for<T>(), not a type inferred back out of a schema value. The name in an error message is the name you chose.
What does not cross the boundary
A Standard Schema issue carries message and
path. Luq's own code
and severity have no field in the spec and do
not survive, so a consumer that switches on issue.code
needs the validator rather than this face — and the same value
is the validator, so nothing has to be given up to get it.
react-hook-form
react-hook-form takes any Standard Schema, so the integration is one line.
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
import { useForm } from "react-hook-form";
import { signupSchema, type Signup } from "./signup-schema";
const { register, handleSubmit, formState } = useForm<Signup>({
resolver: standardSchemaResolver(signupSchema),
});
Pair it with normalize and the payload
reaching handleSubmit is already tidied —
the name trimmed, the email lowercased, the number input's string turned
into a number — with no valueAsNumber and
no per-field setValueAs, because the
resolver reads the value Luq wrote back.
A running form is in examples/react-hook-form
in the repository.
Writing JSON Schema out
toStandardJsonSchema is the same idea one
step further: it attaches a ~standard.jsonSchema
converter as well, so the value satisfies both
StandardSchemaV1 and
StandardJSONSchemaV1. The targets, what
happens to a rule that cannot be expressed, and the ordering it needs are
on the JSON Schema page.