v2.7.0
The wrong rule
does not compile
Your types were probably not written by you. openapi-typescript generates them from a spec you do not own; Prisma from the schema of record; protobuf for four languages at once. When the shape is already decided somewhere else, a validator whose schema is the source of truth asks you to maintain a second description of it. Luq runs the other way: it takes the type you already have and lets you declare rules against its field paths, and the compiler checks those declarations against the type.
Caught by the compiler, not at run time
- A slot unrelated to the field’s typeb.string on a number field
- A missing array wildcard"items.name" instead of "items[*].name"
- Descending into a built-in"when.getTime" on a Date
- A method that does not existinside an element sub-chain
npm install @maroonedog/luqThe type below is the schema, and b.string is not a slot a number field can be.
This does not compile
import { Builder } from "@maroonedog/luq";
import { requiredPlugin } from "@maroonedog/luq/plugins/required";
// The type is the schema, and `age` is a number.
type User = { age: number };
const userValidator = Builder()
.use(requiredPlugin)
.for<User>()
.v("age", (b) => b.string.required())
// ^^^^^^^^ not a slot a number field can be.
.build();The whole API
Declare the rules
import { Builder } from "@maroonedog/luq";
import { requiredPlugin } from "@maroonedog/luq/plugins/required";
import { stringMinPlugin } from "@maroonedog/luq/plugins/stringMin";
import { stringEmailPlugin } from "@maroonedog/luq/plugins/stringEmail";
import { numberMinPlugin } from "@maroonedog/luq/plugins/numberMin";
import { oneOfPlugin } from "@maroonedog/luq/plugins/oneOf";
// The type is yours. Luq neither generates it nor replaces it.
type User = {
name: string;
email: string;
age: number;
role: "admin" | "user";
};
const userValidator = Builder()
.use(requiredPlugin)
.use(stringMinPlugin)
.use(stringEmailPlugin)
.use(numberMinPlugin)
.use(oneOfPlugin)
.for<User>()
.v("name", (b) => b.string.required().min(3))
.v("email", (b) => b.string.required().email())
.v("age", (b) => b.number.required().min(18))
.v("role", (b) => b.string.required().oneOf(["admin", "user"]))
.build();Read the result
// build() returns an object, not a function.
const result = userValidator.validate({
name: "Jo",
email: "jo@example.com",
age: 25,
role: "user",
});
// `valid` is the discriminant. `data` exists only on the success
// branch, so there is no cast and no non-null assertion.
if (result.valid) {
console.log(result.data.name);
} else {
for (const issue of result.issues) {
console.error(`${issue.path}: ${issue.message} (${issue.code})`);
}
}
// name: String must have at least 3 characters, but got 2 (stringMin)What build() gives you
Four members. A ValidationResult<T> is a discriminated union on valid: the success branch carries data, both branches carry issues, and every issue is { path, code, message, severity }.
They are validate, parse, pick and pickAll — all four are on the Validator page.
What it costs to leave
A choice you can undo in an afternoon does not need to be the right one on the first try.
Enter one field at a time
A path you do not declare is not validated, not required and not read. One .v() is the whole unit: cover a single field on a single type, ship it, and leave the rest of the shape alone for as long as you like — a partly-covered type is a normal state rather than a half-finished migration.
Leave without touching your types
Your types were never authored here — .for<Order>() takes the Order you already had — so removing Luq deletes rules and imports, and nothing else.
The call site does not move
tRPC, react-hook-form, Hono and TanStack Form take a Standard Schema, and a Luq validator sits in the slot a zod schema would. Swapping the two changes the value passed in, not the code around it. What you would rewrite is the rules themselves — that part is real, and it is the same work in either direction.
What it looks like in code you already have
The same file before and after, then what a failing path in an array actually names.
What you have now
// Your type, unchanged.
type User = {
name: string;
email: string;
age: number;
};
// Your function, with nothing checking what arrives.
async function saveUser(user: User) {
return api.post("/users", user);
}What you add
import { Builder } from "@maroonedog/luq";
import { requiredPlugin } from "@maroonedog/luq/plugins/required";
import { stringEmailPlugin } from "@maroonedog/luq/plugins/stringEmail";
import { numberMinPlugin } from "@maroonedog/luq/plugins/numberMin";
// Same type as above, and the same HTTP client of your own.
type User = { name: string; email: string; age: number };
declare const api: { post(path: string, body: unknown): Promise<unknown> };
// Same type. No rewrite, no wrapper, no z.infer.
const userValidator = Builder()
.use(requiredPlugin)
.use(stringEmailPlugin)
.use(numberMinPlugin)
.for<User>()
.v("name", (b) => b.string.required())
.v("email", (b) => b.string.required().email())
.v("age", (b) => b.number.required().min(13))
.build();
async function saveUser(input: unknown) {
const result = userValidator.validate(input);
if (!result.valid) {
throw new Error(result.issues.map((issue) => issue.message).join("\n"));
}
// `result.data` is User here, narrowed by `valid`.
return api.post("/users", result.data);
}The type declaration is the same in both columns. Being type-first is what makes adoption a patch: what you add is rules, and only rules.
A path points at real data
Nested fields use dots and array elements use [*], but the issue you get back names the element that actually failed. A form can bind an error to a row without parsing the path back apart.
import { Builder } from "@maroonedog/luq";
import { requiredPlugin } from "@maroonedog/luq/plugins/required";
import { stringMinPlugin } from "@maroonedog/luq/plugins/stringMin";
import { numberMinPlugin } from "@maroonedog/luq/plugins/numberMin";
import { arrayMinLengthPlugin } from "@maroonedog/luq/plugins/arrayMinLength";
type Order = {
customer: { name: string };
items: { productId: string; quantity: number }[];
};
const orderValidator = Builder()
.use(requiredPlugin)
.use(stringMinPlugin)
.use(numberMinPlugin)
.use(arrayMinLengthPlugin)
.for<Order>()
.v("customer.name", (b) => b.string.required().min(2))
.v("items", (b) => b.array.required().minLength(1))
.v("items[*].productId", (b) => b.string.required().min(5))
.v("items[*].quantity", (b) => b.number.required().min(1))
.build();
const result = orderValidator.validate({
customer: { name: "Acme" },
items: [
{ productId: "PROD-1", quantity: 1 },
{ productId: "X", quantity: 1 },
],
});
console.log(result.issues.map((issue) => issue.path));
// -> ["items[1].productId"] — the real index, never "items[*]"What it enforces, what it covers, what it costs
A plugin you did not import has no method
There is no registry to populate and no barrel you have to pay for. .use() puts a plugin in the builder’s bag, and the bag decides which methods exist on which slots — so an unimported rule is not merely absent at runtime, it does not typecheck.
import { Builder } from "@maroonedog/luq";
import { requiredPlugin } from "@maroonedog/luq/plugins/required";
type Draft = { title: string };
const draftValidator = Builder()
.use(requiredPlugin)
.for<Draft>()
// `.min(3)` is not offered here: stringMinPlugin
// was never `use`d, so the method does not exist.
.v("title", (b) => b.string.required())
.build();Rules that read other fields
compareField resolves the other field’s path once, at build time.
type Signup = {
password: string;
confirmPassword: string;
contactMethod: "email" | "phone";
phone?: string;
};
Builder()
.use(requiredPlugin)
.use(stringMinPlugin)
.use(compareFieldPlugin)
.use(requiredIfPlugin)
.for<Signup>()
.v("password", (b) => b.string.required().min(12))
.v("confirmPassword", (b) =>
b.string.required().compareField("password")
)
.v("phone", (b) =>
b.string.requiredIf((data) => data.contactMethod === "phone")
)
.build();JSON Schema, with the score written down
929 of 929 cases on the official Draft-07 suite — required tests only, skipped cases counted as failures. Read it against the trivial floor: a validator that returned true unconditionally scores 551 / 929 = 59.31% on the same corpus.
import { fromJsonSchema } from "@maroonedog/luq/plugins/jsonSchemaFullFeature";
type Account = { email: string; age?: number };
const accountValidator = fromJsonSchema<Account>({
type: "object",
properties: {
email: { type: "string", format: "email" },
age: { type: "number", minimum: 18 },
},
required: ["email"],
});
// 929 / 929 = 100.00% — docs/json-schema-conformance.mdYou pay for what you import
Measured with esbuild and gzip. The core is 30.1% of the all-plugins build — the floor you pay before importing anything.
// Six plugins, six imports.
import { requiredPlugin } from "@maroonedog/luq/plugins/required";
import { stringMinPlugin } from "@maroonedog/luq/plugins/stringMin";
import { stringMaxPlugin } from "@maroonedog/luq/plugins/stringMax";
import { stringEmailPlugin } from "@maroonedog/luq/plugins/stringEmail";
import { numberMinPlugin } from "@maroonedog/luq/plugins/numberMin";
import { numberMaxPlugin } from "@maroonedog/luq/plugins/numberMax";
// Measured gzip — config/size-budget.json:
// Builder alone ............. 8,508 B
// Builder + these six ....... 9,381 B
// Builder + all 77 plugins .. 28,250 BWhere to start
Install it, declare one rule against a type you already have, and read the result — that is the whole first hour. It is slower than 1.x on flat and nested shapes — the benchmarks page says by how much.
Coming from 1.x, the mapping from the old Result to today's discriminated union is on the Validator page.