bun/zod-prefer-lazy-schema

Disallow building a validation schema while the module loads

A Zod schema at the top level of a module is built as soon as the module is imported, even if you never validate anything with it. With hundreds of schemas, that makes every start slower.

The rule reports a chain of calls that starts with z, where z is imported or global, if it runs while the module loads. That includes static class fields and functions that are called where they're written. A schema inside another schema isn't reported again.

Configure#

Off by default. To turn it on:

bunfig.toml
[lint.rules]
"bun/zod-prefer-lazy-schema" = "error" # or "warn"

Every option, set to its default. The ones that are commented out are unset by default.

bunfig.toml
[lint.rules]
"bun/zod-prefer-lazy-schema" = ["error", {
  roots = ["z"],
  methods = [], # list of strings
  # factorySuffix = <string>,
  # lazyWrapper = <string>,
}]

Examples#

example.ts1 problem
import { z } from "zod";

export const User = z.object({
  name: z.string().min(1),
  email: z.string().email(),
});
example.tsNo problems
import { z } from "zod";

let user: z.ZodType | undefined;

export function userSchema() {
  return (user ??= z.object({
    name: z.string().min(1),
    email: z.string().email(),
  }));
}

z.lazy() doesn't build anything until it's used, so it's allowed:

example.tsNo problems
import { z } from "zod";

export const Comment = z.lazy(() => z.object({ text: z.string() }));

Options#

OptionDefaultWhat it does
roots["z"]The names that a schema starts with, such as the z in z.object().
methods[]Methods that build a schema, no matter what they're called on, such as extend.
factorySuffixnoneAlso report calls to functions whose name ends in this string.
lazyWrappernoneThe name of your helper that builds a schema on first use. The message then shows how to call it.
example.tswith { "roots": ["v"] }1 problem
import * as v from "valibot";

export const Email = v.pipe(v.string(), v.email());
example.tswith { "methods": ["extend"] }1 problem
import { User } from "./user";

export const Admin = User.extend({ level: 1 });
example.tswith { "factorySuffix": "Schema" }1 problem
import { createUserSchema } from "./user";

export const User = createUserSchema();

lazyWrapper doesn't change what's reported. A schema in a function that you pass to a helper is allowed with or without it:

example.tswith { "lazyWrapper": "once" }No problems
import { z } from "zod";
import { once } from "./once";

export const user = once(() => z.object({ name: z.string() }));

When not to use it#

In a small program, or a server that starts once and runs for days, the time to build the schemas doesn't matter.