Isolated, composable Zod refiner functions — copied into your project, owned by you.
See the codeIsolated, composable Zod refiner functions — copied into your project, owned by you.
A shadcn-style add workflow for cross-field validation.
npx zod-refiners init
npx zod-refiners add password-match-refiner
RefineTuple contractZod has a .refine() method, and it is quietly one of the most powerful
things in the library — it is where you express the rules that no single
field can express alone:
type is "invoice", is vatNumber present?endDate come after startDate?In practice, writing those rules is the least pleasant part of using Zod.
You end up hand-writing predicate functions, assembling
{ message, path } objects yourself, remembering to point the error at the
right field, and pasting the same tuple into every form that needs it.
zod-refiners is built on one belief: cross-field validation rules are
reusable code, and reusable code should be copyable, not imported.
So it works like shadcn/ui, but for validation logic. There is a registry of small, isolated refiner functions. You pick the ones you need, and the CLI copies the source files straight into your project. From that moment they are yours: readable, editable, debuggable, free of any dependency on this package.
.refine(
...createPasswordMatchRefiner<SignupForm>("password", "confirmPassword"),
)
That one spread is the whole product. Everything else — the CLI, the manifest, the dependency resolution — exists to get that function onto your disk, with its types, in the right folder, in the right order.
add is a plain .ts file in your
repo. No runtime dependency is added, no package version can break you,
and you can rewrite every line.RefineTuple. It imports nothing
but a shared type. It has no side effects, no config object, no
framework knowledge.commander,
prompts, picocolors), Node's standard fs, a JSON manifest. No
plugin system, no daemon, no codegen server.…use a validation library that ships everything built in?
Because the rules that ship in someone else's package are the rules you cannot easily change. When a refiner lives in your repo, the day its behavior needs to differ for your product, you edit it — you are not waiting on an upstream release or maintaining a fork of a whole library.
…copy the snippet from the docs once?
You can, and many people do. This project exists because "once" turns
into five forms, three repos, and a Slack thread where someone pastes a
version that is subtly different from yours. The registry keeps the
canonical source, the CLI keeps it consistent, and registryDependencies
makes sure the shared types arrive with it.
…just import zod-refiners as a library?
That is the trade-off this project deliberately rejects: an imported
helper is a permanent dependency — versioned, audited, and opaque. A
copied helper is a file you can read in ten seconds. The cost is that you
do not receive automatic bug fixes; re-running add and accepting the
overwrite prompt is how you opt into upstream improvements.
init, list, add. Source files land in
your project; the package never runs in production.init is optional; add writes the config
for you on first run.RefineTuple<T>), errors carry path arrays Zod understands.npm install --save-dev zod-refiners
or with pnpm:
pnpm add -D zod-refiners
The CLI is a development-time tool — like a formatter or a generator,
nothing about it ships to production. Installing it as a dev dependency
keeps it out of your production install; running it via npx with no
install at all also works.
Requirements
| Node.js | >= 18 |
| Zod | >= 3.22.0 (your project's peer dependency) |
| Package manager | any — the CLI does not care |
Verify it:
npx zod-refiners list
1. Initialize (optional — add will do it for you if you skip this):
npx zod-refiners init
? Where should refiners be installed? › src/lib/refiners
Created zod-refiners.json (refinersDir = "src/lib/refiners")
2. Add a refiner:
npx zod-refiners add password-match-refiner
Added src/lib/refiners/types.ts
Added src/lib/refiners/password-match-refiner.ts
Done.
Notice that types.ts was installed without being asked for — it is a
registryDependency of the password refiner, so the closure pulled it in.
3. Use it:
// src/lib/refiners/password-match-refiner.ts was copied into your project
import { z } from "zod";
import { createPasswordMatchRefiner } from "@/lib/refiners/password-match-refiner";
type SignupForm = {
email: string;
password: string;
confirmPassword: string;
};
const signupSchema = z
.object({
email: z.string().email(),
password: z.string().min(8),
confirmPassword: z.string(),
})
.refine(
...createPasswordMatchRefiner<SignupForm>(
"password",
"confirmPassword",
"Passwords don't match",
),
);
const result = signupSchema.safeParse({
email: "ada@example.com",
password: "hunter22222",
confirmPassword: "hunter2222",
});
// result.success === false
// result.error.issues[0].path === ["confirmPassword"] ← error on the confirm field
That is the entire integration. No provider, no plugin registration, no
import from zod-refiners anywhere in your application code.
zod-refiners initCreates zod-refiners.json in the current working directory by asking
where refiner files should live.
| Behavior | Detail |
|---|---|
| Already configured | Prints Already configured. refinersDir = "..." and exits 0 without prompting |
| Prompt default | src/lib/refiners (press Enter to accept) |
| Empty input | Falls back to the default directory |
| Output | Writes zod-refiners.json with 2-space indentation |
zod-refiners listLoads registry/index.json and prints every installable refiner with its
description.
types entry is intentionally hidden — it is installed
automatically as a dependency and is not something you ask for by name.zod-refiners add <refiners...>Installs one or more refiners — plus their transitive dependencies.
npx zod-refiners add password-match-refiner
npx zod-refiners add password-match-refiner another-refiner
Flow:
zod-refiners.json; if missing, prompts for
refinersDir and writes it (same as init).Collision handling — if a destination file already exists:
? src/lib/refiners/types.ts already exists. Overwrite? › (y/N)
The prompt defaults to No. Declining prints Skipped <file> and
moves on; accepting copies the new version over the old one. Declining
everything is a safe way to inspect what an update would change.
Exit codes
| Code | Meaning |
|---|---|
0 | Success (including "nothing to do") |
1 | Unknown refiner name, or a circular dependency in the registry |
Unknown refiner "nope". Run "zod-refiners list" to see options.
Circular refiner dependency: a -> b -> a
zod-refiners.json, at the root of your project:
{
"refinersDir": "src/lib/refiners"
}
| Field | Type | Meaning |
|---|---|---|
refinersDir | string | Directory that receives copied refiner files, resolved relative to the working directory you run the CLI from |
There are no other settings, by design. If you want a refiner somewhere else, change this string. If you want it under a different name, rename the file after it is copied — the tool never looks at your project's imports.
A common setup is to alias the folder so the copies are pleasant to import:
// tsconfig.json
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"],
},
},
}
import { createPasswordMatchRefiner } from "@/lib/refiners/password-match-refiner";
password-match-refinerValidates that two fields hold the same value. Built for confirmation fields — and it reports the error on the confirmation field, not on the original, so your form highlights the field the user actually got wrong.
npx zod-refiners add password-match-refiner
Installs:
password-match-refiner.ts — the factorytypes.ts — the shared RefineTuple type (dependency)Signature
function createPasswordMatchRefiner<T extends Record<string, unknown>>(
passwordField: keyof T & string,
confirmField: keyof T & string,
message?: string, // default: "Passwords don't match"
): RefineTuple<T>;
Usage
import { z } from "zod";
import { createPasswordMatchRefiner } from "@/lib/refiners/password-match-refiner";
type SettingsForm = {
password: string;
confirmPassword: string;
};
const settingsSchema = z
.object({
password: z.string().min(8),
confirmPassword: z.string(),
})
.refine(
...createPasswordMatchRefiner<SettingsForm>(
"password",
"confirmPassword",
"Your passwords must match",
),
);
Behavior
| Case | Result |
|---|---|
| Values equal | Parses successfully |
| Values differ | Issue at path: ["confirmPassword"] with your message |
| Works with | string, number, or any ===-comparable values |
strong-password-refinerValidates a configurable password-strength policy and reports the
first rule that fails — "too short" instead of one generic
"password is invalid" message. Every rule's wording is overridable
through options.messages.
npx zod-refiners add strong-password-refiner
Installs:
strong-password-refiner.ts — the factorytypes.ts — the shared RefineTuple type (dependency)Signature
function createStrongPasswordRefiner<T extends Record<string, unknown>>(
field: keyof T & string,
options?: StrongPasswordOptions,
): RefineTuple<T>;
Options (defaults shown)
{
minLength: 8,
maxLength: 128,
requireUppercase: true,
requireLowercase: true,
requireDigit: true,
requireSpecialChar: true,
specialChars: "!@#$%^&*()_+-=[]{};':\"\\|,.<>/?",
forbidWhitespace: true,
forbidRepeatingChars: false,
messages: {}, // per-rule overrides: tooShort, tooLong, missingUppercase,
// missingLowercase, missingDigit, missingSpecialChar,
// containsWhitespace, repeatingChars, invalidType,
// generic (fallback before any rule has failed)
}
Usage
import { z } from "zod";
import { createStrongPasswordRefiner } from "@/lib/refiners/strong-password-refiner";
type SignupForm = { password: string };
const signupSchema = z.object({ password: z.string() }).refine(
...createStrongPasswordRefiner<SignupForm>("password", {
minLength: 10,
messages: { tooShort: "Use at least 10 characters" },
}),
);
Behavior
| Case | Result |
|---|---|
| All rules pass | Parses successfully |
| A rule fails | Issue at path: ["password"] with the first failing rule's message |
| Non-string value | Issue at path: ["password"] with the invalidType message |
minLength > maxLength | Throws at construction time (config error) |
The tuple's second element is a plain { message, path } object, as
RefineTuple requires. The predicate writes the first failing rule's
message into it before returning false, and Zod reads it back when
building the issue.
date-range-refinerValidates that an end date comes after a start date. Built for booking, scheduling, and filter forms — and it puts the error on the field you configured for ordering problems (the end date by default), while missing or invalid values are always reported on the field that's actually wrong.
npx zod-refiners add date-range-refiner
Installs:
date-range-refiner.ts — the factorytypes.ts — the shared RefineTuple type (dependency)Signature
function createDateRangeRefiner<T extends Record<string, unknown>>(
startField: keyof T & string,
endField: keyof T & string,
options?: DateRangeOptions,
): RefineTuple<T>;
Options (defaults shown)
{
allowEqual: false, // false = end must be strictly after start;
// true = a same-day/same-instant range is valid
granularity: "date", // "date" = compare local calendar days (times ignored);
// "datetime" = compare exact timestamps
errorField: "end", // "start" | "end" — where ordering errors land
messages: {}, // per-rule overrides: datesRequired,
// invalidDate, endNotAfterStart
}
Usage
import { z } from "zod";
import { createDateRangeRefiner } from "@/lib/refiners/date-range-refiner";
type BookingForm = {
startDate: Date;
endDate: Date;
};
const bookingSchema = z
.object({ startDate: z.date(), endDate: z.date() })
.refine(
...createDateRangeRefiner<BookingForm>("startDate", "endDate", {
allowEqual: true,
granularity: "datetime",
messages: { endNotAfterStart: "Pick an end time after the start" },
}),
);
Behavior
| Case | Result |
|---|---|
| End after start | Parses successfully |
| End before start | Issue at path: ["endDate"] (or errorField) with the ordering message |
Same day, granularity: "date" | Passes only when allowEqual: true |
Same timestamp, granularity: "datetime" | Passes only when allowEqual: true |
Start or end missing (null/undefined) | Issue at path of the missing field with the datesRequired message |
Value that isn't a usable Date (wrong type or Invalid Date) | Issue at path of the offending field with the invalidDate message |
startField === endField | Throws at construction time (config error) |
With the default "date" granularity the comparison uses local calendar
days, so 2026-01-01T18:00 → 2026-01-02T09:00 is a valid range even
though it's less than 24 hours. Switch to "datetime" when the times of
day matter.
The default endNotAfterStart message adapts to allowEqual:
"End date must be after start date" when it's false, "End date must be
on or after start date" when it's true.
allowed-domains-refinerValidates that a field's email, URL, or hostname belongs to an allowed set of domains. Built for restricting signups and invites to your own domain(s), allowlisting webhook/callback URLs (SSRF defense), and pinning asset URLs to trusted hosts — the error always lands on the field you refined.
npx zod-refiners add allowed-domains-refiner
Installs:
allowed-domains-refiner.ts — the factorytypes.ts — the shared RefineTuple type (dependency)Signature
function createAllowedDomainsRefiner<T extends Record<string, unknown>>(
field: keyof T & string,
options: AllowedDomainsOptions,
message?: string, // default: "This domain isn't allowed"
): RefineTuple<T>;
Options (defaults shown)
{
domains: string[], // required — the allowlist, at least one entry
source: "email", // "email" | "url" | "hostname" — where the
// domain is extracted from
caseSensitive: false, // false = compare domains case-insensitively
allowSubdomains: false, // false = exact match only;
// true = "mail.company.com" matches "company.com"
}
Sources — how the domain is read from the field's value:
source | Value it expects | Domain taken from |
|---|---|---|
"email" (default) | dev@company.com | Everything after the last @ |
"url" | https://api.company.com/hook | The URL hostname (port ignored) |
"hostname" | mail.company.com | The whole value |
Usage
import { z } from "zod";
import { createAllowedDomainsRefiner } from "@/lib/refiners/allowed-domains-refiner";
type InviteForm = { workEmail: string };
const inviteSchema = z
.object({ workEmail: z.string().email() })
.refine(
...createAllowedDomainsRefiner<InviteForm>(
"workEmail",
{ domains: ["company.com", "company.io"] },
"Use your company email",
),
);
Behavior
| Case | Result |
|---|---|
| Domain on the allowlist | Parses successfully |
| Domain not on the allowlist | Issue at path: ["workEmail"] with your message |
Subdomain, allowSubdomains: false (default) | Rejected (mail.company.com ≠ company.com) |
Subdomain, allowSubdomains: true | Accepted (exact matches keep working too) |
Lookalike domain (notcompany.com, company.com.evil.com) | Rejected — the match is on a real domain boundary |
Wrong type, empty value, no @, or unparseable URL | Rejected with your message |
domains: [] | Throws at construction time (config error) |
Comparison is case-insensitive by default (Dev@Company.COM matches
company.com); set caseSensitive: true when the allowlist itself is
case-sensitive. With allowSubdomains: true, matching still ends at a
domain boundary, so company.com.evil.com never passes.
typesNot installed by name — it follows automatically whenever a refiner needs it. It exists so every refiner can share one contract:
export type RefineTuple<T> = [
(data: T) => boolean,
{ message: string; path: string[] },
];
RefineTuple contractEverything in this project is an instance of one type. A RefineTuple
is exactly what Zod's .refine() accepts when you spread it:
type RefineTuple<T> = [
(data: T) => boolean, // 1. predicate over the whole parsed object
// 2. where the error goes, and what it says
{ message: string; path: string[] },
];
| Element | Role |
|---|---|
[0] | Receives the entire object, not one field. Return true when the data is valid. |
[1].message | The error message shown to the user, displayed when the predicate fails. |
[1].path | The field path the error is attached to. Zod renders it under that key, which is what makes precise, per-field errors possible. |
Because the tuple is designed for the spread operator, a refiner call reads the same as a hand-written refinement — just with the implementation moved somewhere it can be reused:
// hand-written
.refine((d) => d.password === d.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
})
// with a refiner — same semantics, one line, reusable
.refine(
...createPasswordMatchRefiner<Form>("password", "confirmPassword"),
)
Two rules make this composable:
path always points at the field responsible for the failure.
For a two-field rule, that is a judgement call — password-match-refiner
deliberately blames the confirmation field.$ npx zod-refiners add password-match-refiner
┌──────────────┐ no config ┌───────────────────────────┐
│ ensureConfig │ ───────────────► │ prompt for refinersDir │
│ │ │ write zod-refiners.json │
└──────┬───────┘ └───────────────────────────┘
│
▼
┌──────────────┐
│ loadManifest │ registry/index.json ──► [{name, description,
└──────┬───────┘ files, registryDependencies}]
▼
┌────────────────┐ "password-match-refiner" needs "types"
│ resolveClosure │ ─────────────────────────────────────────┐
└──────┬─────────┘ │
│ unknown name ──► error, exit 1 │
│ cycle ──► error, exit 1 │
▼ ▼
ordered: [types, password-match-refiner] (topological, deduped)
▼
┌───────────┐ dest exists? ──► prompt (default: No) ──► skip / overwrite
│ copyEntry │
└───────────┘ mkdir recursive + copyFile ──► "Added src/lib/refiners/..."
Dependency resolution is a depth-first walk over the manifest:
registryDependencies are visited before the entry
itself, so files land in a usable order;Set guarantees each file is copied once no matter how many refiners
request it;a -> b -> a);list.Reading and writing uses Node's standard fs/promises — no
fs-extra, no runtime schema for the config file: init and add
serialize zod-refiners.json with two-space indentation and a trailing
newline.
A refiner is a factory: it takes the configuration a call site needs and
returns a RefineTuple. Start from this template — it passes strict
TypeScript and is exactly the shape the registry expects:
// registry/no-whitespace-refiner.ts
import type { RefineTuple } from "./types";
/**
* Validates that a field contains no whitespace.
*
* @example
* .refine(...createNoWhitespaceRefiner<FormValues>("username"))
*/
export function createNoWhitespaceRefiner<T extends Record<string, unknown>>(
field: keyof T & string,
message = "Whitespace is not allowed",
): RefineTuple<T> {
return [
(data) => !/\s/.test(String(data[field] ?? "")),
{ message, path: [field] },
];
}
Then register it in registry/index.json:
{
"name": "no-whitespace-refiner",
"description": "Rejects values containing spaces, tabs, or newlines.",
"files": ["no-whitespace-refiner.ts"],
"registryDependencies": ["types"]
}
| Manifest field | Meaning |
|---|---|
name | What users type in add <name> |
description | Shown by list — say what rule it enforces and where the error lands |
files | Files copied into refinersDir, relative to registry/ |
registryDependencies | Other entry names that must be installed first (types in almost every case) |
Check your work:
pnpm build
node bin/zod-refiners.js list
node bin/zod-refiners.js add no-whitespace-refiner
data argument, never throwspath points at the field the user should fixmessage that a human would want to see@example showing the .refine(...) spreadRecord<string, unknown> so it types against
any Zod object schema./types (or other refiners you declare in
registryDependencies)tsc --strictzod-refiners/
├── bin/
│ └── zod-refiners.js # executable shim → dist/cli.js
├── dist/ # compiled output (generated, gitignored)
├── registry/
│ ├── index.json # the manifest: names, files, dependencies
│ ├── types.ts # RefineTuple contract
│ ├── password-match-refiner.ts
│ └── strong-password-refiner.ts
├── src/
│ ├── cli.ts # commander commands: init / list / add
│ ├── config.ts # read & write zod-refiners.json
│ ├── registry.ts # manifest loading, closure resolution, copying
│ └── fsutil.ts # pathExists / readJson helpers (node:fs)
├── package.json
└── tsconfig.json # strict, NodeNext, outDir: dist
Two halves, cleanly split:
src/ is the tool. It never runs in a user's production app.registry/ is the product. Everything in it is copied verbatim
into user projects, which is why it depends on nothing but ./types.Contributions are welcome — new refiners especially. Every refiner merged into the registry is one fewer refiner anyone else has to write by hand.
registry/ must stay
dependency-free and self-contained; it is going into other people's
repos.add produces.git clone https://github.com/usefmahmud/zod-refiners.git
cd zod-refiners
pnpm install
pnpm build
Exercise the CLI locally against a scratch directory:
mkdir /tmp/zod-refiners-test && cd /tmp/zod-refiners-test
node /path/to/zod-refiners/bin/zod-refiners.js list
node /path/to/zod-refiners/bin/zod-refiners.js add password-match-refiner
Useful commands:
| Command | Effect |
|---|---|
pnpm build | Compile src/ → dist/ with tsc (this is the gate every PR must pass) |
node bin/zod-refiners.js <cmd> | Run the CLI from your working tree |
git checkout -b feat/my-refinerregistry/my-refiner.ts following the template aboveregistry/index.jsonpnpm build, then add it into a scratch directory and confirm the
copied file compiles under --strict in a real schema.refine(...) examplepnpm build passes with no errorsregistry/index.json stays valid JSON with registryDependencies
that actually exist@example)feat:, fix:, docs:, refactor:, chore:Open an issue on GitHub with:
Does add modify my package.json?
No. It copies source files. The only thing it writes outside the refiners
folder is zod-refiners.json.
Do the copied files import from zod-refiners?
Never. That is the point. The only import a copied refiner has is
./types; your application imports it alongside zod and nothing else.
What happens when I run add and the file is already there?
You get a per-file confirmation defaulting to No. Nothing is ever
overwritten silently, which makes re-running add a safe way to see what
changed upstream.
How do I get updates to a refiner I already installed?
Run add again and accept the overwrite. You will lose local edits to
that file — read the new copy first if you have customized it.
Can I edit the copied files? Yes, they are yours now. The only consequence is that upstream updates will conflict with your edits, and the overwrite prompt is where you decide which version wins.
Unknown refiner "x" — what now?
The name is not in the manifest. Run npx zod-refiners list, and check
for typos. Names are case-sensitive.
Circular refiner dependency — what now?
Two registry entries depend on each other. This is a bug in the registry,
not in your project — please open an issue with the refiner names.
ESM or CommonJS?
The CLI is CommonJS and runs under either module system; Node >= 18
handles it. The copied refiners are plain TypeScript — your build tools
compile them however your project already works.
Does it work with Zod v4?
Yes. The RefineTuple shape — a predicate plus { message, path } — is
accepted by Zod 3 and Zod 4, and the examples in this README were run
against Zod 4.
Windows?
The CLI uses node:path throughout, so paths behave correctly on
Windows, Linux, and macOS.
Why is there no plugin/runtime API? Because a runtime API would reintroduce the dependency this project exists to remove. The registry is data, the CLI is a copier, and your code stays yours.
MIT © usefmahmud
.refine() is the foundation everything here
builds onTypeScript
99.9%
Isolated, composable Zod refiner functions — copied into your project, owned by you.
See the codeIsolated, composable Zod refiner functions — copied into your project, owned by you.
A shadcn-style add workflow for cross-field validation.
npx zod-refiners init
npx zod-refiners add password-match-refiner
RefineTuple contractZod has a .refine() method, and it is quietly one of the most powerful
things in the library — it is where you express the rules that no single
field can express alone:
type is "invoice", is vatNumber present?endDate come after startDate?In practice, writing those rules is the least pleasant part of using Zod.
You end up hand-writing predicate functions, assembling
{ message, path } objects yourself, remembering to point the error at the
right field, and pasting the same tuple into every form that needs it.
zod-refiners is built on one belief: cross-field validation rules are
reusable code, and reusable code should be copyable, not imported.
So it works like shadcn/ui, but for validation logic. There is a registry of small, isolated refiner functions. You pick the ones you need, and the CLI copies the source files straight into your project. From that moment they are yours: readable, editable, debuggable, free of any dependency on this package.
.refine(
...createPasswordMatchRefiner<SignupForm>("password", "confirmPassword"),
)
That one spread is the whole product. Everything else — the CLI, the manifest, the dependency resolution — exists to get that function onto your disk, with its types, in the right folder, in the right order.
add is a plain .ts file in your
repo. No runtime dependency is added, no package version can break you,
and you can rewrite every line.RefineTuple. It imports nothing
but a shared type. It has no side effects, no config object, no
framework knowledge.commander,
prompts, picocolors), Node's standard fs, a JSON manifest. No
plugin system, no daemon, no codegen server.…use a validation library that ships everything built in?
Because the rules that ship in someone else's package are the rules you cannot easily change. When a refiner lives in your repo, the day its behavior needs to differ for your product, you edit it — you are not waiting on an upstream release or maintaining a fork of a whole library.
…copy the snippet from the docs once?
You can, and many people do. This project exists because "once" turns
into five forms, three repos, and a Slack thread where someone pastes a
version that is subtly different from yours. The registry keeps the
canonical source, the CLI keeps it consistent, and registryDependencies
makes sure the shared types arrive with it.
…just import zod-refiners as a library?
That is the trade-off this project deliberately rejects: an imported
helper is a permanent dependency — versioned, audited, and opaque. A
copied helper is a file you can read in ten seconds. The cost is that you
do not receive automatic bug fixes; re-running add and accepting the
overwrite prompt is how you opt into upstream improvements.
init, list, add. Source files land in
your project; the package never runs in production.init is optional; add writes the config
for you on first run.RefineTuple<T>), errors carry path arrays Zod understands.npm install --save-dev zod-refiners
or with pnpm:
pnpm add -D zod-refiners
The CLI is a development-time tool — like a formatter or a generator,
nothing about it ships to production. Installing it as a dev dependency
keeps it out of your production install; running it via npx with no
install at all also works.
Requirements
| Node.js | >= 18 |
| Zod | >= 3.22.0 (your project's peer dependency) |
| Package manager | any — the CLI does not care |
Verify it:
npx zod-refiners list
1. Initialize (optional — add will do it for you if you skip this):
npx zod-refiners init
? Where should refiners be installed? › src/lib/refiners
Created zod-refiners.json (refinersDir = "src/lib/refiners")
2. Add a refiner:
npx zod-refiners add password-match-refiner
Added src/lib/refiners/types.ts
Added src/lib/refiners/password-match-refiner.ts
Done.
Notice that types.ts was installed without being asked for — it is a
registryDependency of the password refiner, so the closure pulled it in.
3. Use it:
// src/lib/refiners/password-match-refiner.ts was copied into your project
import { z } from "zod";
import { createPasswordMatchRefiner } from "@/lib/refiners/password-match-refiner";
type SignupForm = {
email: string;
password: string;
confirmPassword: string;
};
const signupSchema = z
.object({
email: z.string().email(),
password: z.string().min(8),
confirmPassword: z.string(),
})
.refine(
...createPasswordMatchRefiner<SignupForm>(
"password",
"confirmPassword",
"Passwords don't match",
),
);
const result = signupSchema.safeParse({
email: "ada@example.com",
password: "hunter22222",
confirmPassword: "hunter2222",
});
// result.success === false
// result.error.issues[0].path === ["confirmPassword"] ← error on the confirm field
That is the entire integration. No provider, no plugin registration, no
import from zod-refiners anywhere in your application code.
zod-refiners initCreates zod-refiners.json in the current working directory by asking
where refiner files should live.
| Behavior | Detail |
|---|---|
| Already configured | Prints Already configured. refinersDir = "..." and exits 0 without prompting |
| Prompt default | src/lib/refiners (press Enter to accept) |
| Empty input | Falls back to the default directory |
| Output | Writes zod-refiners.json with 2-space indentation |
zod-refiners listLoads registry/index.json and prints every installable refiner with its
description.
types entry is intentionally hidden — it is installed
automatically as a dependency and is not something you ask for by name.zod-refiners add <refiners...>Installs one or more refiners — plus their transitive dependencies.
npx zod-refiners add password-match-refiner
npx zod-refiners add password-match-refiner another-refiner
Flow:
zod-refiners.json; if missing, prompts for
refinersDir and writes it (same as init).Collision handling — if a destination file already exists:
? src/lib/refiners/types.ts already exists. Overwrite? › (y/N)
The prompt defaults to No. Declining prints Skipped <file> and
moves on; accepting copies the new version over the old one. Declining
everything is a safe way to inspect what an update would change.
Exit codes
| Code | Meaning |
|---|---|
0 | Success (including "nothing to do") |
1 | Unknown refiner name, or a circular dependency in the registry |
Unknown refiner "nope". Run "zod-refiners list" to see options.
Circular refiner dependency: a -> b -> a
zod-refiners.json, at the root of your project:
{
"refinersDir": "src/lib/refiners"
}
| Field | Type | Meaning |
|---|---|---|
refinersDir | string | Directory that receives copied refiner files, resolved relative to the working directory you run the CLI from |
There are no other settings, by design. If you want a refiner somewhere else, change this string. If you want it under a different name, rename the file after it is copied — the tool never looks at your project's imports.
A common setup is to alias the folder so the copies are pleasant to import:
// tsconfig.json
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"],
},
},
}
import { createPasswordMatchRefiner } from "@/lib/refiners/password-match-refiner";
password-match-refinerValidates that two fields hold the same value. Built for confirmation fields — and it reports the error on the confirmation field, not on the original, so your form highlights the field the user actually got wrong.
npx zod-refiners add password-match-refiner
Installs:
password-match-refiner.ts — the factorytypes.ts — the shared RefineTuple type (dependency)Signature
function createPasswordMatchRefiner<T extends Record<string, unknown>>(
passwordField: keyof T & string,
confirmField: keyof T & string,
message?: string, // default: "Passwords don't match"
): RefineTuple<T>;
Usage
import { z } from "zod";
import { createPasswordMatchRefiner } from "@/lib/refiners/password-match-refiner";
type SettingsForm = {
password: string;
confirmPassword: string;
};
const settingsSchema = z
.object({
password: z.string().min(8),
confirmPassword: z.string(),
})
.refine(
...createPasswordMatchRefiner<SettingsForm>(
"password",
"confirmPassword",
"Your passwords must match",
),
);
Behavior
| Case | Result |
|---|---|
| Values equal | Parses successfully |
| Values differ | Issue at path: ["confirmPassword"] with your message |
| Works with | string, number, or any ===-comparable values |
strong-password-refinerValidates a configurable password-strength policy and reports the
first rule that fails — "too short" instead of one generic
"password is invalid" message. Every rule's wording is overridable
through options.messages.
npx zod-refiners add strong-password-refiner
Installs:
strong-password-refiner.ts — the factorytypes.ts — the shared RefineTuple type (dependency)Signature
function createStrongPasswordRefiner<T extends Record<string, unknown>>(
field: keyof T & string,
options?: StrongPasswordOptions,
): RefineTuple<T>;
Options (defaults shown)
{
minLength: 8,
maxLength: 128,
requireUppercase: true,
requireLowercase: true,
requireDigit: true,
requireSpecialChar: true,
specialChars: "!@#$%^&*()_+-=[]{};':\"\\|,.<>/?",
forbidWhitespace: true,
forbidRepeatingChars: false,
messages: {}, // per-rule overrides: tooShort, tooLong, missingUppercase,
// missingLowercase, missingDigit, missingSpecialChar,
// containsWhitespace, repeatingChars, invalidType,
// generic (fallback before any rule has failed)
}
Usage
import { z } from "zod";
import { createStrongPasswordRefiner } from "@/lib/refiners/strong-password-refiner";
type SignupForm = { password: string };
const signupSchema = z.object({ password: z.string() }).refine(
...createStrongPasswordRefiner<SignupForm>("password", {
minLength: 10,
messages: { tooShort: "Use at least 10 characters" },
}),
);
Behavior
| Case | Result |
|---|---|
| All rules pass | Parses successfully |
| A rule fails | Issue at path: ["password"] with the first failing rule's message |
| Non-string value | Issue at path: ["password"] with the invalidType message |
minLength > maxLength | Throws at construction time (config error) |
The tuple's second element is a plain { message, path } object, as
RefineTuple requires. The predicate writes the first failing rule's
message into it before returning false, and Zod reads it back when
building the issue.
date-range-refinerValidates that an end date comes after a start date. Built for booking, scheduling, and filter forms — and it puts the error on the field you configured for ordering problems (the end date by default), while missing or invalid values are always reported on the field that's actually wrong.
npx zod-refiners add date-range-refiner
Installs:
date-range-refiner.ts — the factorytypes.ts — the shared RefineTuple type (dependency)Signature
function createDateRangeRefiner<T extends Record<string, unknown>>(
startField: keyof T & string,
endField: keyof T & string,
options?: DateRangeOptions,
): RefineTuple<T>;
Options (defaults shown)
{
allowEqual: false, // false = end must be strictly after start;
// true = a same-day/same-instant range is valid
granularity: "date", // "date" = compare local calendar days (times ignored);
// "datetime" = compare exact timestamps
errorField: "end", // "start" | "end" — where ordering errors land
messages: {}, // per-rule overrides: datesRequired,
// invalidDate, endNotAfterStart
}
Usage
import { z } from "zod";
import { createDateRangeRefiner } from "@/lib/refiners/date-range-refiner";
type BookingForm = {
startDate: Date;
endDate: Date;
};
const bookingSchema = z
.object({ startDate: z.date(), endDate: z.date() })
.refine(
...createDateRangeRefiner<BookingForm>("startDate", "endDate", {
allowEqual: true,
granularity: "datetime",
messages: { endNotAfterStart: "Pick an end time after the start" },
}),
);
Behavior
| Case | Result |
|---|---|
| End after start | Parses successfully |
| End before start | Issue at path: ["endDate"] (or errorField) with the ordering message |
Same day, granularity: "date" | Passes only when allowEqual: true |
Same timestamp, granularity: "datetime" | Passes only when allowEqual: true |
Start or end missing (null/undefined) | Issue at path of the missing field with the datesRequired message |
Value that isn't a usable Date (wrong type or Invalid Date) | Issue at path of the offending field with the invalidDate message |
startField === endField | Throws at construction time (config error) |
With the default "date" granularity the comparison uses local calendar
days, so 2026-01-01T18:00 → 2026-01-02T09:00 is a valid range even
though it's less than 24 hours. Switch to "datetime" when the times of
day matter.
The default endNotAfterStart message adapts to allowEqual:
"End date must be after start date" when it's false, "End date must be
on or after start date" when it's true.
allowed-domains-refinerValidates that a field's email, URL, or hostname belongs to an allowed set of domains. Built for restricting signups and invites to your own domain(s), allowlisting webhook/callback URLs (SSRF defense), and pinning asset URLs to trusted hosts — the error always lands on the field you refined.
npx zod-refiners add allowed-domains-refiner
Installs:
allowed-domains-refiner.ts — the factorytypes.ts — the shared RefineTuple type (dependency)Signature
function createAllowedDomainsRefiner<T extends Record<string, unknown>>(
field: keyof T & string,
options: AllowedDomainsOptions,
message?: string, // default: "This domain isn't allowed"
): RefineTuple<T>;
Options (defaults shown)
{
domains: string[], // required — the allowlist, at least one entry
source: "email", // "email" | "url" | "hostname" — where the
// domain is extracted from
caseSensitive: false, // false = compare domains case-insensitively
allowSubdomains: false, // false = exact match only;
// true = "mail.company.com" matches "company.com"
}
Sources — how the domain is read from the field's value:
source | Value it expects | Domain taken from |
|---|---|---|
"email" (default) | dev@company.com | Everything after the last @ |
"url" | https://api.company.com/hook | The URL hostname (port ignored) |
"hostname" | mail.company.com | The whole value |
Usage
import { z } from "zod";
import { createAllowedDomainsRefiner } from "@/lib/refiners/allowed-domains-refiner";
type InviteForm = { workEmail: string };
const inviteSchema = z
.object({ workEmail: z.string().email() })
.refine(
...createAllowedDomainsRefiner<InviteForm>(
"workEmail",
{ domains: ["company.com", "company.io"] },
"Use your company email",
),
);
Behavior
| Case | Result |
|---|---|
| Domain on the allowlist | Parses successfully |
| Domain not on the allowlist | Issue at path: ["workEmail"] with your message |
Subdomain, allowSubdomains: false (default) | Rejected (mail.company.com ≠ company.com) |
Subdomain, allowSubdomains: true | Accepted (exact matches keep working too) |
Lookalike domain (notcompany.com, company.com.evil.com) | Rejected — the match is on a real domain boundary |
Wrong type, empty value, no @, or unparseable URL | Rejected with your message |
domains: [] | Throws at construction time (config error) |
Comparison is case-insensitive by default (Dev@Company.COM matches
company.com); set caseSensitive: true when the allowlist itself is
case-sensitive. With allowSubdomains: true, matching still ends at a
domain boundary, so company.com.evil.com never passes.
typesNot installed by name — it follows automatically whenever a refiner needs it. It exists so every refiner can share one contract:
export type RefineTuple<T> = [
(data: T) => boolean,
{ message: string; path: string[] },
];
RefineTuple contractEverything in this project is an instance of one type. A RefineTuple
is exactly what Zod's .refine() accepts when you spread it:
type RefineTuple<T> = [
(data: T) => boolean, // 1. predicate over the whole parsed object
// 2. where the error goes, and what it says
{ message: string; path: string[] },
];
| Element | Role |
|---|---|
[0] | Receives the entire object, not one field. Return true when the data is valid. |
[1].message | The error message shown to the user, displayed when the predicate fails. |
[1].path | The field path the error is attached to. Zod renders it under that key, which is what makes precise, per-field errors possible. |
Because the tuple is designed for the spread operator, a refiner call reads the same as a hand-written refinement — just with the implementation moved somewhere it can be reused:
// hand-written
.refine((d) => d.password === d.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
})
// with a refiner — same semantics, one line, reusable
.refine(
...createPasswordMatchRefiner<Form>("password", "confirmPassword"),
)
Two rules make this composable:
path always points at the field responsible for the failure.
For a two-field rule, that is a judgement call — password-match-refiner
deliberately blames the confirmation field.$ npx zod-refiners add password-match-refiner
┌──────────────┐ no config ┌───────────────────────────┐
│ ensureConfig │ ───────────────► │ prompt for refinersDir │
│ │ │ write zod-refiners.json │
└──────┬───────┘ └───────────────────────────┘
│
▼
┌──────────────┐
│ loadManifest │ registry/index.json ──► [{name, description,
└──────┬───────┘ files, registryDependencies}]
▼
┌────────────────┐ "password-match-refiner" needs "types"
│ resolveClosure │ ─────────────────────────────────────────┐
└──────┬─────────┘ │
│ unknown name ──► error, exit 1 │
│ cycle ──► error, exit 1 │
▼ ▼
ordered: [types, password-match-refiner] (topological, deduped)
▼
┌───────────┐ dest exists? ──► prompt (default: No) ──► skip / overwrite
│ copyEntry │
└───────────┘ mkdir recursive + copyFile ──► "Added src/lib/refiners/..."
Dependency resolution is a depth-first walk over the manifest:
registryDependencies are visited before the entry
itself, so files land in a usable order;Set guarantees each file is copied once no matter how many refiners
request it;a -> b -> a);list.Reading and writing uses Node's standard fs/promises — no
fs-extra, no runtime schema for the config file: init and add
serialize zod-refiners.json with two-space indentation and a trailing
newline.
A refiner is a factory: it takes the configuration a call site needs and
returns a RefineTuple. Start from this template — it passes strict
TypeScript and is exactly the shape the registry expects:
// registry/no-whitespace-refiner.ts
import type { RefineTuple } from "./types";
/**
* Validates that a field contains no whitespace.
*
* @example
* .refine(...createNoWhitespaceRefiner<FormValues>("username"))
*/
export function createNoWhitespaceRefiner<T extends Record<string, unknown>>(
field: keyof T & string,
message = "Whitespace is not allowed",
): RefineTuple<T> {
return [
(data) => !/\s/.test(String(data[field] ?? "")),
{ message, path: [field] },
];
}
Then register it in registry/index.json:
{
"name": "no-whitespace-refiner",
"description": "Rejects values containing spaces, tabs, or newlines.",
"files": ["no-whitespace-refiner.ts"],
"registryDependencies": ["types"]
}
| Manifest field | Meaning |
|---|---|
name | What users type in add <name> |
description | Shown by list — say what rule it enforces and where the error lands |
files | Files copied into refinersDir, relative to registry/ |
registryDependencies | Other entry names that must be installed first (types in almost every case) |
Check your work:
pnpm build
node bin/zod-refiners.js list
node bin/zod-refiners.js add no-whitespace-refiner
data argument, never throwspath points at the field the user should fixmessage that a human would want to see@example showing the .refine(...) spreadRecord<string, unknown> so it types against
any Zod object schema./types (or other refiners you declare in
registryDependencies)tsc --strictzod-refiners/
├── bin/
│ └── zod-refiners.js # executable shim → dist/cli.js
├── dist/ # compiled output (generated, gitignored)
├── registry/
│ ├── index.json # the manifest: names, files, dependencies
│ ├── types.ts # RefineTuple contract
│ ├── password-match-refiner.ts
│ └── strong-password-refiner.ts
├── src/
│ ├── cli.ts # commander commands: init / list / add
│ ├── config.ts # read & write zod-refiners.json
│ ├── registry.ts # manifest loading, closure resolution, copying
│ └── fsutil.ts # pathExists / readJson helpers (node:fs)
├── package.json
└── tsconfig.json # strict, NodeNext, outDir: dist
Two halves, cleanly split:
src/ is the tool. It never runs in a user's production app.registry/ is the product. Everything in it is copied verbatim
into user projects, which is why it depends on nothing but ./types.Contributions are welcome — new refiners especially. Every refiner merged into the registry is one fewer refiner anyone else has to write by hand.
registry/ must stay
dependency-free and self-contained; it is going into other people's
repos.add produces.git clone https://github.com/usefmahmud/zod-refiners.git
cd zod-refiners
pnpm install
pnpm build
Exercise the CLI locally against a scratch directory:
mkdir /tmp/zod-refiners-test && cd /tmp/zod-refiners-test
node /path/to/zod-refiners/bin/zod-refiners.js list
node /path/to/zod-refiners/bin/zod-refiners.js add password-match-refiner
Useful commands:
| Command | Effect |
|---|---|
pnpm build | Compile src/ → dist/ with tsc (this is the gate every PR must pass) |
node bin/zod-refiners.js <cmd> | Run the CLI from your working tree |
git checkout -b feat/my-refinerregistry/my-refiner.ts following the template aboveregistry/index.jsonpnpm build, then add it into a scratch directory and confirm the
copied file compiles under --strict in a real schema.refine(...) examplepnpm build passes with no errorsregistry/index.json stays valid JSON with registryDependencies
that actually exist@example)feat:, fix:, docs:, refactor:, chore:Open an issue on GitHub with:
Does add modify my package.json?
No. It copies source files. The only thing it writes outside the refiners
folder is zod-refiners.json.
Do the copied files import from zod-refiners?
Never. That is the point. The only import a copied refiner has is
./types; your application imports it alongside zod and nothing else.
What happens when I run add and the file is already there?
You get a per-file confirmation defaulting to No. Nothing is ever
overwritten silently, which makes re-running add a safe way to see what
changed upstream.
How do I get updates to a refiner I already installed?
Run add again and accept the overwrite. You will lose local edits to
that file — read the new copy first if you have customized it.
Can I edit the copied files? Yes, they are yours now. The only consequence is that upstream updates will conflict with your edits, and the overwrite prompt is where you decide which version wins.
Unknown refiner "x" — what now?
The name is not in the manifest. Run npx zod-refiners list, and check
for typos. Names are case-sensitive.
Circular refiner dependency — what now?
Two registry entries depend on each other. This is a bug in the registry,
not in your project — please open an issue with the refiner names.
ESM or CommonJS?
The CLI is CommonJS and runs under either module system; Node >= 18
handles it. The copied refiners are plain TypeScript — your build tools
compile them however your project already works.
Does it work with Zod v4?
Yes. The RefineTuple shape — a predicate plus { message, path } — is
accepted by Zod 3 and Zod 4, and the examples in this README were run
against Zod 4.
Windows?
The CLI uses node:path throughout, so paths behave correctly on
Windows, Linux, and macOS.
Why is there no plugin/runtime API? Because a runtime API would reintroduce the dependency this project exists to remove. The registry is data, the CLI is a copier, and your code stays yours.
MIT © usefmahmud
.refine() is the foundation everything here
builds onTypeScript
99.9%