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

account.ts
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.

both-faces.ts
// 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.

normalize, in Getting started

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.

SignupForm.tsx
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.