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. Nothing checks that the two still agree; they drift silently, and the first sign is a value that validated against the copy and does not fit the original.

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.

Luq is for the other case — when the type already exists and something else generated it.

The direction is the whole difference

Nearly every widely used validator in TypeScript works one way: you write a schema, and the type is inferred out of it. Luq runs the other way. You keep the type, and the rules are declared against it.

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. What Luq adds is the enforcement: the path, the slot and the method are each checked by the compiler, so a rule that does not apply to the field it names is a type error rather than a rule that quietly never matches.

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. If a schema is the only place the shape exists in your codebase, the shorter column is also the honest answer.

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?

What the type-first direction buys

Because the type is already known when the rules are read, the compiler can check the rules against it. These are errors, not rules that compile and then never fire:

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

Written plainly, because you will find all of it out anyway:

Speed 2.0 is slower than Luq 1.x on flat and nested shapes — three to nine times, measured head to head. Arrays and JSON Schema are faster. The numbers are on the benchmarks page. No figure for any other library is published here, because this repository has not measured one.
Ecosystem zod has years of integrations, recipes and answered questions. Luq has a Standard Schema adapter and this site.
Adoption Small 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.
Inference Luq 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));