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.

Install
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.

7,420 B
Builder alone, gzipped
config/size-budget.json
77
plugins, one import each
config/plugin-catalog.lock.json
100.00%
JSON Schema Draft-07
config/json-schema-suite.json
0
eval / new Function
npm run check:no-dynamic-code

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

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

save-user.ts
// 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. 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. 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. 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

save-user.ts
// 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

save-user.ts
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.

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

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

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

imports.ts
// 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.

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

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