Isolated, 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
- The idea
- Why not just…
- Highlights
- Installation
- Quick start
- CLI reference
- Configuration
- Available refiners
- The
RefineTuplecontract - How it works
- Writing your own refiner
- Project structure
- Contributing
- FAQ
- License
Zod 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:
- do these two password fields match?
- if
typeis"invoice", isvatNumberpresent? - does
endDatecome afterstartDate? - is at least one contact method provided?
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.
- You own the code. The output of
addis a plain.tsfile in your repo. No runtime dependency is added, no package version can break you, and you can rewrite every line. - Refiners are pure and isolated. Each refiner is a single function
that takes field names and returns a
RefineTuple. It imports nothing but a shared type. It has no side effects, no config object, no framework knowledge. - Composition over configuration. Every refiner handles exactly one concern and is designed to be spread alongside others. Combine ten of them and each error still lands on its own field.
- Small and boring. Three runtime dependencies (
commander,prompts,picocolors), Node's standardfs, a JSON manifest. No plugin system, no daemon, no codegen server. - Escapable by default. Delete the refiners folder and your project still builds — you just lost those helpers. Nothing else couples to this tool.
…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.
- shadcn-style workflow —
init,list,add. Source files land in your project; the package never runs in production. - Automatic dependency closure — add a refiner and its shared types come with it, topologically ordered, exactly once.
- Safe by default — existing files are never overwritten silently; every collision asks first and defaults to No.
- Honest errors — unknown refiners and circular registry dependencies are detected and reported by name, with a non-zero exit code.
- Tiny surface — three commands, one config file, one JSON manifest.
- Zero config to start —
initis optional;addwrites the config for you on first run. - TypeScript-first — strict-mode compiled, the refiner contract is a
type (
RefineTuple<T>), errors carrypatharrays Zod understands.
npm install --save-dev zod-refinersor with pnpm:
pnpm add -D zod-refinersThe 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 list1. 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-refinerAdded 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 fieldThat is the entire integration. No provider, no plugin registration, no
import from zod-refiners anywhere in your application code.
Creates 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 |
Loads registry/index.json and prints every installable refiner with its
description.
- The internal
typesentry is intentionally hidden — it is installed automatically as a dependency and is not something you ask for by name. - Exits non-zero if the manifest cannot be read or parsed.
Installs one or more refiners — plus their transitive dependencies.
npx zod-refiners add password-match-refiner
npx zod-refiners add password-match-refiner another-refinerFlow:
- Config — reads
zod-refiners.json; if missing, prompts forrefinersDirand writes it (same asinit). - Manifest — loads the registry index.
- Closure — resolves the requested names into a topologically ordered install list. Direct dependencies are installed before the refiners that need them.
- Copy — for each entry, ensures the target directory exists and copies every file listed in the entry.
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:
import { createPasswordMatchRefiner } from "@/lib/refiners/password-match-refiner";Validates 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-refinerInstalls:
password-match-refiner.ts— the factorytypes.ts— the sharedRefineTupletype (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 |
Validates 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-refinerInstalls:
strong-password-refiner.ts— the factorytypes.ts— the sharedRefineTupletype (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.
Validates 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-refinerInstalls:
date-range-refiner.ts— the factorytypes.ts— the sharedRefineTupletype (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.
Validates 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-refinerInstalls:
allowed-domains-refiner.ts— the factorytypes.ts— the sharedRefineTupletype (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.
Not 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[] },
];Everything 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:
- The predicate only ever reads the data it is given. No captured state, no I/O, no throwing.
- The
pathalways points at the field responsible for the failure. For a two-field rule, that is a judgement call —password-match-refinerdeliberately 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:
- each entry's
registryDependenciesare visited before the entry itself, so files land in a usable order; - a
Setguarantees each file is copied once no matter how many refiners request it; - revisiting an in-progress node means the registry has a cycle, and the
error names the full path (
a -> b -> a); - a missing node means you typed a name that does not exist, and the
error tells you to run
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- Pure predicate — reads only its
dataargument, never throws -
pathpoints at the field the user should fix - Default
messagethat a human would want to see - JSDoc with an
@exampleshowing the.refine(...)spread - Generic constrained to
Record<string, unknown>so it types against any Zod object schema - No imports besides
./types(or other refiners you declare inregistryDependencies) - Works under
tsc --strict
zod-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.
- Refiners are copy-out, not import-in. Code in
registry/must stay dependency-free and self-contained; it is going into other people's repos. - Behavior changes to copied refiners are breaking changes. People
own their copies; be conservative about altering what
addproduces. - Small PRs. One refiner, one fix, one doc improvement per PR.
git clone https://github.com/usefmahmud/zod-refiners.git
cd zod-refiners
pnpm install
pnpm buildExercise 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-refinerUseful 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 |
- Fork and create a branch:
git checkout -b feat/my-refiner - Add
registry/my-refiner.tsfollowing the template above - Add its entry to
registry/index.json pnpm build, thenaddit into a scratch directory and confirm the copied file compiles under--strictin a real schema- Open a PR with: the rule it enforces, the field the error should land
on, and a before/after
.refine(...)example
-
pnpm buildpasses with no errors - New refiners follow the checklist
-
registry/index.jsonstays valid JSON withregistryDependenciesthat actually exist - Docs updated (README usage examples, JSDoc
@example) - Commit messages follow Conventional Commits
—
feat:,fix:,docs:,refactor:,chore:
Open an issue on GitHub with:
- your Node and Zod versions
- the exact command you ran
- the expected vs. actual output (the CLI's error messages are precise — paste them verbatim)
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
- shadcn/ui — for the idea that the best way to ship reusable components is to stop treating them as a dependency
- Zod —
.refine()is the foundation everything here builds on