ProvenPin Install

React Hook Form + Zod 4 resolver schemas (no DOM): working versions (tested 2026-10-15)

Share one Zod schema between React Hook Form (via @hookform/resolvers) and the server, with cross-field refinements, tested by calling the resolver directly.

passed4 passed · 0 failed2026-10-15 13:33 UTC
Status
passed
Last passed
2026-10-15 13:33 UTC
Tests
4 / 4
Language
TypeScript
Runtime
node v22.23.3
pinned versions6
react-hook-form@7.90.0@hookform/resolvers@5.9.1zod@4.6.7react@19.3.1tsx@4.23.15@types/node@24.19.2
run it
npm install --no-audit --no-fund --ignore-scripts=false
npm test
test environment
node v22.23.3
linux 6.17.0-1022-azure
x64
runner: github-actions 38742087302

The code

Exactly the files that passed. Your agent can pull them with get_recipe("react-hook-form-zod-resolver") (install), or see them on GitHub.

package.json
{
  "name": "react-hook-form-zod-resolver",
  "private": true,
  "type": "module",
  "scripts": {
    "test": "node --import tsx --test test/*.test.ts"
  },
  "dependencies": {
    "react-hook-form": "7.90.0",
    "@hookform/resolvers": "5.9.1",
    "zod": "4.6.7",
    "react": "19.3.1"
  },
  "devDependencies": {
    "tsx": "4.23.15",
    "@types/node": "24.19.2"
  }
}
src/schema.ts
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";

export const SignupSchema = z.object({
  email: z.email("Enter a valid email"),
  password: z.string().min(8, "At least 8 characters"),
  confirm: z.string(),
  age: z.coerce.number().int().min(18, "Must be 18+"), // inputs give strings; coerce to number
}).refine((d) => d.password === d.confirm, { path: ["confirm"], message: "Passwords do not match" });

export type SignupInput = z.input<typeof SignupSchema>;
export type SignupValues = z.output<typeof SignupSchema>;

// In a component: useForm<SignupInput, unknown, SignupValues>({ resolver: signupResolver })
export const signupResolver = zodResolver(SignupSchema);
test/schema.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { signupResolver, SignupSchema } from "../src/schema.ts";

const run = (values: Record<string, unknown>) =>
  signupResolver(values as any, undefined, { fields: {}, shouldUseNativeValidation: false } as any);

test("valid values come back coerced", async () => {
  const r = await run({ email: "[email protected]", password: "longpass1", confirm: "longpass1", age: "30" });
  assert.deepEqual(r.errors, {});
  assert.equal((r.values as any).age, 30);
});

test("field and cross-field errors map to RHF errors", async () => {
  const r = await run({ email: "nope", password: "short", confirm: "other", age: "12" });
  assert.equal((r.errors as any).email.message, "Enter a valid email");
  assert.equal((r.errors as any).password.message, "At least 8 characters");
  assert.equal((r.errors as any).age.message, "Must be 18+");
});

test("refinement error lands on confirm", async () => {
  const r = await run({ email: "[email protected]", password: "longpass1", confirm: "longpass2", age: 20 });
  assert.equal((r.errors as any).confirm.message, "Passwords do not match");
});

test("same schema validates on the server", () => {
  assert.equal(SignupSchema.safeParse({ email: "[email protected]", password: "longpass1", confirm: "longpass1", age: 40 }).success, true);
});

Connect to ProvenPin

Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.

terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp

Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.