v2.1.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 generates them from the
schema of record; protobuf generates them for four languages at once. A
validator whose schema is the source of truth asks you to write that shape a
second time, and nothing checks that the copy still agrees with the original.
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, so a rule that does not apply to the field it is written on is an error rather than a rule that quietly never fires.
That matters most when the code calling this library is generated rather than typed by hand. A generator that picks the wrong rule, misspells a path or drops an array wildcard gets a red squiggle — not a validator that passes everything.
npm install @maroonedog/luq Rejected at compile time
- A slot unrelated to the field’s type b.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 exist inside an element sub-chain
Each of these is pinned by a @ts-expect-error in
test/type/. TypeScript reports an unused directive,
so a green typecheck is itself the proof that they still fail.
Every number on this site was measured on this repository, and the file it came from is named beside it. Where a measurement is worse than the 1.x release, it is written down as worse — see the benchmarks.
The whole thing, on one screen
A builder that takes your type, and a result you narrow with
valid. There is no Result
class, no unwrap(), and
build() returns an object rather than a function.
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 }.
-
validate(value, options?)A ValidationResult<T> holding the original value. -
parse(value, options?)A ValidationResult<TParsed> holding the value after transforms. -
pick(path)A single-field validator for one declared path. -
pickAll(paths)A validator returning exactly those paths, keyed by the path string.
How it works
Three steps, and none of them ask you to restate a type you have already written.
- 1
Keep your type
The TypeScript type you already wrote is the schema. Nothing is generated, nothing is inferred back out of a schema object.
- 2
Declare rules against its paths
A path that does not exist on the type is a compile error. So is choosing a slot the field’s type cannot be.
- 3
Read a discriminated union
validate() hands back { valid: true, data } or { valid: false, issues }. The branch you are on decides what exists.
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);
} 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.
The wildcard is a declaration, not a report.
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 Luq actually does
Five claims, each with the code that backs it and the file the number came from.
Your type is the schema
No interface gets rewritten and no type gets inferred back out of a schema object. A path that does not exist on the type is a compile error, and so is choosing a slot the field’s type cannot be.
type User = {
name: string;
email: string;
age: number;
};
Builder()
.use(requiredPlugin)
.use(stringMinPlugin)
.for<User>()
.v("name", (b) => b.string.required().min(3))
// .v("nmae", ...) -> compile error: no such path
// .v("age", b => b.string...) -> compile error: wrong slot
.build(); 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(); You pay for what you import
Measured with esbuild and gzip, the same options 1.x’s own comparison used. The core is 30.9% of the all-plugins build — the floor you pay before using anything is small, which is the part a bundle-size claim usually hides.
// 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 ............. 7,420 B
// Builder + these six ....... 8,373 B
// Builder + all 76 plugins .. 24,040 B Rules that read other fields
compareField resolves the other field’s path once, at build time. requiredIf is a presence rule rather than a check, so it decides the field’s gate instead of only rejecting an empty string that already got through.
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.md Coming from 1.x?
This release is a rewrite, and it breaks things on purpose.
build() returns an object,
result.isValid() and
result.errors are gone in favour of
result.valid and
result.issues, and
abortEarly still defaults to
true. It is also slower than 1.x on flat and
nested shapes; the benchmarks page says by how much rather than leaving it out.