Choosing

Luq or zod?

Where does your type come from?

A validator whose schema is the source of truth assumes you are the one who decides the shape. Often you are not — it arrives already decided:

In all four the shape is upstream and not yours to move, so a schema-first validator asks you to write it a second time and keep the copy in step by hand. The compiler can be made to check the copy against the original — satisfies z.ZodType<Order> does exactly that — but the copy still has to be authored, updated, and remembered.

The short answer

If you are starting from nothing and the schema will be the source of truth, use zod. It is the default for good reasons: it is mature, it is everywhere, and every question you will have is already answered somewhere.

If the shape arrives already decided — a generated type, a spec you consume, a schema of record — use Luq. The rules are declared against the type that is already there, so there is no second description of the shape to author, update and keep in step.

Neither direction is better in the abstract. Which one fits depends on a question you can answer immediately: where does the type come from today?

Where schema-first is the better fit

A codebase should have one source of truth for a shape. If that source is a schema you author, schema-first is the coherent arrangement, and Luq cannot give you that.

Luq's case is the one where the artefact already exists elsewhere and is not yours to move. Then the schema-first arrangement asks you to author a second source of truth, and the question stops being which library is nicer and becomes which copy is right.

One thing that is easy to miss: Luq contains both directions. fromJsonSchema(document) is schema-first, and the document decides. What Luq declines to do is make you hand-write the second copy.

It patches onto the types you already have

This is what being type-first buys you in an existing codebase, and it is the main practical reason to reach for Luq: your type definitions do not change. Not re-authored as a schema, not replaced by an inferred one, not moved. .for<Order>() takes the Order that is already there, and every rule is declared against it.

So Luq asks you to describe the rules, not the shape.

Which is what makes adoption a patch rather than a migration. Nothing here is global — no registry, no installation step, no shared configuration object; a validator is a value in a module:

The direction is the whole difference

Nearly every widely used validator in TypeScript infers the type out of a schema you write. Luq runs the other way.

Luq is not the first to run that way. fluentvalidation-ts has declared rules against an existing type with a fluent chain since 2019. The direction is older than this library and the credit is not ours.

Schema first
import { z } from "zod";

// The schema is the source. The type comes out of it.
const User = z.object({
  name: z.string().min(3),
  age: z.number().min(18),
});

type User = z.infer<typeof User>;

const result = User.safeParse({ name: "Jo", age: 25 });

if (result.success) {
  console.log(result.data.name);
} else {
  for (const issue of result.error.issues) {
    console.error(issue.path.join("."), issue.message);
  }
}
Type first
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";

// The type is the source. The rules are declared against it.
type User = {
  name: string;
  age: number;
};

const validateUser = Builder()
  .use(requiredPlugin)
  .use(stringMinPlugin)
  .use(numberMinPlugin)
  .for<User>()
  .v("name", (b) => b.string.required().min(3))
  .v("age", (b) => b.number.required().min(18))
  .build();

const result = validateUser.validate({ name: "Jo", age: 25 });

if (result.valid) {
  console.log(result.data.name);
} else {
  for (const issue of result.issues) {
    console.error(issue.path, issue.message);
  }
}

Same job, same depth on both sides, and the Luq column is the longer one. The extra lines are not scattered: they are one plugin import plus one matching .use() per rule kind, and the type written out as a type instead of falling out of the schema. That is the cost of the direction — nothing is in the bundle that you did not name, and the type is yours rather than inferred — and it is a real cost, not a rounding error.

What the type-first direction buys

Because the type is known when the rules are read, these are compile errors:

Each is pinned by a @ts-expect-error in test/type/. TypeScript reports an unused directive, so a green typecheck is the proof that they still fail.

What you give up by choosing this direction

Speed2.0 is slower than Luq 1.x on a single field, a multi-field object and a nested object — ×1.2 to ×4.8, measured head to head, and faster on an array and a JSON Schema document. The per-shape numbers are on the benchmarks page.Against the other libraries, measured in this repository against the same values rather than quoted from theirs: Luq is faster than zod on all 4 shapes, by ×1.1 to ×2.0; faster than valibot on 1 of the 4 shapes measured — an array, by ×1.0 — and slower on a single field, a multi-field object and a nested object by ×1.1 to ×1.9; slower than ajv on all 4 shapes, by ×5.9 to ×10; faster than yup on all 4 shapes, by ×21 to ×52. Every pairing, the wins and the losses, is in the comparison table.Those are mixed pools — half the values in them fail — so they measure error construction as much as validation. On accepted input alone the count changes: zod 0 of 4 rather than 4 of 4; valibot 2 of 4 rather than 1 of 4.
Ecosystemzod has years of integrations, recipes and answered questions. Luq has a Standard Schema adapter and this site.
AdoptionSmall enough that you will find bugs nobody has hit yet. The gates in this repository are the reason to think there are fewer of them, not a promise that there are none.
InferenceLuq will not hand you a type. That is the point of the direction, but it means there is nothing to reach for when you genuinely have no type yet.

It is not either/or at the boundary

Luq implements Standard Schema v1, so anything that accepts one — tRPC, TanStack Form, Hono, t3-env — takes a Luq validator wherever it takes a zod schema. Using both in one codebase is a normal outcome, not a migration failure.

standard-schema.ts
// Standard Schema means this is not an either/or at the boundary.
import { toStandardSchema } from "@maroonedog/luq/standard-schema";

// Anything that accepts a Standard Schema takes either one.
router.input(toStandardSchema(validateUser));