Email Validation with Zod and tRPC: End-to-End Type-Safe Verification
The Runtime Error You’re Not Seeing Yet
You’ve got a TypeScript monorepo. Your API layer uses tRPC. Your forms use Zod. Types everywhere. And somewhere between the React component and the database, a string you thought was an email address turns out to be "" or undefined or ceo@company. and your server throws a 500 at 2am.
tRPC doesn’t automatically solve the “is this email actually deliverable” problem. It solves the “does the payload match what both sides expect” problem. Those are different problems. You need both solutions.
This post shows how to wire them together: a shared Zod schema that validates structure, a tRPC procedure that calls a real verification API, and types that flow from the React component all the way to the database insert with zero any assertions.
Why tRPC + Zod Changes the Validation Equation
With a REST API, you define your request shape in one place (the server), document it in another (OpenAPI spec or README), and hope the client developer reads both. Type drift is a constant tax. Someone changes the server response and forgets to update the client type. Someone adds a field. Someone renames one. You find out at runtime.
tRPC eliminates that class of bug entirely. The router is the spec. The client infers types directly from the router definition, not from a separate schema file or generated code. Change the server, the client breaks at compile time. Not at runtime. Not in production.
Add Zod into the mix and you get runtime enforcement on top of compile-time enforcement. Same schema. Two guarantees.
For email validation specifically, this matters. You’re calling an external API (MailCop) and getting back a structured response. With tRPC output validation, that response shape is typed and verified. If the API ever returns an unexpected shape, you catch it immediately.
The Shared Schema File
Put your schemas where both server and client can import them. In a Next.js App Router project, that’s lib/schemas/. In a monorepo, a shared packages/schemas package works. The important thing: one file, not two.
// lib/schemas/email-verification.ts
import { z } from "zod";
export const verifyEmailInput = z.object({
email: z
.string()
.min(1, "Email is required")
.email("Enter a valid email address"),
});
export const verifyEmailOutput = z.object({
email: z.string(),
status: z.enum(["deliverable", "undeliverable", "risky", "unknown"]),
disposable: z.boolean(),
catchAll: z.boolean(),
});
export type VerifyEmailInput = z.infer<typeof verifyEmailInput>;
export type VerifyEmailOutput = z.infer<typeof verifyEmailOutput>;
verifyEmailInput is the shape tRPC validates before the procedure body runs. verifyEmailOutput is what the procedure returns, validated against the external API response. Both are exported as types so components don’t need to import Zod directly.
The status enum is important. Don’t type this as string. The four values represent distinct behaviors: deliverable addresses get created, undeliverable addresses get rejected, risky and unknown addresses can go either way depending on your risk tolerance. A string type loses that information. An enum preserves it and makes exhaustive switch statements possible.
The tRPC Router Setup
If you haven’t set up tRPC v11 yet, the initialization looks like this:
// server/trpc.ts
import { initTRPC } from "@trpc/server";
import superjson from "superjson";
export const t = initTRPC.context<{ userId?: string }>().create({
transformer: superjson,
});
export const router = t.router;
export const publicProcedure = t.procedure;
export const createCallerFactory = t.createCallerFactory;
superjson handles Date objects, Maps, Sets, and other types that JSON can’t serialize. Worth the dependency. Without it, any Date field in your response comes back as a string and you’re adding .map(r => ({ ...r, createdAt: new Date(r.createdAt) })) everywhere. In tRPC v11, you also need to add transformer: superjson to your client-side HTTP link (e.g., httpBatchLink({ transformer: superjson })). The server initTRPC.create() and the client link both need the transformer.
createCallerFactory is exported here because you’ll need it for testing. We’ll get to that.
The Verification Procedure
Here’s where it gets interesting. The procedure validates input with Zod, calls the MailCop API, validates the response with Zod, and returns a fully typed result. No any. No type assertions.
// server/routers/email.ts
import { TRPCError } from "@trpc/server";
import { publicProcedure, router } from "../trpc";
import { verifyEmailInput, verifyEmailOutput } from "@/lib/schemas/email-verification";
export const emailRouter = router({
verify: publicProcedure
.input(verifyEmailInput)
.output(verifyEmailOutput)
.mutation(async ({ input }) => {
let apiResponse: Response;
try {
apiResponse = await fetch("https://api.mailcop.net/v1/verify", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TRUEMAIL_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ email: input.email }),
signal: AbortSignal.timeout(4000),
});
} catch (err) {
// Timeout or network failure: return unknown status, don't block signup
return {
email: input.email,
status: "unknown" as const,
disposable: false,
catchAll: false,
};
}
if (!apiResponse.ok) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Email verification service unavailable",
});
}
const raw = await apiResponse.json();
// Parse and validate the API response against our schema.
// If MailCop ever changes their response shape, this throws
// a ZodError caught by tRPC and surfaced as a 500.
const result = verifyEmailOutput.parse({
email: raw.email,
status: raw.result,
disposable: raw.is_disposable ?? false,
catchAll: raw.is_catch_all ?? false,
});
return result;
}),
});
Three things to notice.
.output(verifyEmailOutput) runs the Zod parse on whatever you return. If the shape doesn’t match, tRPC throws before the response leaves the server. Your client never sees a malformed payload.
The fail-open pattern in the catch block returns "unknown" instead of throwing. A network timeout on your validation service shouldn’t block a real user from signing up. Accept the submission, flag the email as unverified, run a background check later. The real-time vs bulk validation post covers the architecture for that async path.
raw.result maps to our status field because MailCop’s API response uses result as the key. The explicit mapping is intentional. When the external API shape changes, this is the one place you fix it.
The Root Router and API Handler
Wire the email router into your main router:
// server/routers/_app.ts
import { router } from "../trpc";
import { emailRouter } from "./email";
export const appRouter = router({
email: emailRouter,
});
export type AppRouter = typeof appRouter;
// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@/server/routers/_app";
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: () => ({}),
});
export { handler as GET, handler as POST };
Standard tRPC v11 App Router setup. Nothing specific to email here.
The Client-Side Hook
On the client, @trpc/tanstack-react-query gives you typed mutations with full React Query integration. The input and return types are inferred directly from the router. You don’t import VerifyEmailInput or VerifyEmailOutput in the component. They’re already there.
// components/signup-form.tsx
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTRPC } from "@/lib/trpc/client";
import { useMutation } from "@tanstack/react-query";
import { verifyEmailInput, type VerifyEmailInput } from "@/lib/schemas/email-verification";
export function SignupForm() {
const trpc = useTRPC();
const verifyEmail = useMutation(trpc.email.verify.mutationOptions());
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting },
} = useForm<VerifyEmailInput>({
resolver: zodResolver(verifyEmailInput),
mode: "onBlur",
});
const onSubmit = async (data: VerifyEmailInput) => {
const result = await verifyEmail.mutateAsync(data);
if (result.status === "undeliverable") {
setError("email", { message: "That email address isn't deliverable" });
return;
}
if (result.disposable) {
setError("email", { message: "Disposable addresses aren't allowed" });
return;
}
// Proceed with account creation...
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<div>
<label htmlFor="email">Email</label>
<input id="email" type="email" {...register("email")} />
{errors.email && <p role="alert">{errors.email.message}</p>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Checking..." : "Create account"}
</button>
</form>
);
}
result.status is typed as "deliverable" | "undeliverable" | "risky" | "unknown". TypeScript will yell at you if you try to compare it to a string that’s not in the enum. result.disposable is boolean, not boolean | undefined. The types are correct because the schema is correct.
Sound familiar? This is what developers mean when they talk about end-to-end type safety. Not “I wrote TypeScript on both sides.” It means the types actually connect. Change the output schema on the server, the result.status comparison on the client breaks at compile time.
Adding a Rate Limiting Middleware
Real applications need rate limiting on the verification endpoint. You don’t want someone submitting your form 500 times a minute. tRPC middleware is the right place for this.
// server/middleware/rate-limit.ts
import { TRPCError } from "@trpc/server";
import { t } from "../trpc";
const requestCounts = new Map<string, { count: number; resetAt: number }>();
export const rateLimitMiddleware = t.middleware(({ ctx, next }) => {
const identifier = ctx.userId ?? "anonymous";
const window = 60 * 1000; // 1 minute
const limit = 10;
const now = Date.now();
const entry = requestCounts.get(identifier);
if (!entry || now > entry.resetAt) {
requestCounts.set(identifier, { count: 1, resetAt: now + window });
} else if (entry.count >= limit) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: "Too many verification requests. Try again in a minute.",
});
} else {
entry.count++;
}
return next({ ctx });
});
export const rateLimitedProcedure = t.procedure.use(rateLimitMiddleware);
Swap publicProcedure for rateLimitedProcedure in the email router. The middleware runs before the procedure body on every call. No changes needed to the procedure itself.
For production, replace the in-memory Map with Redis. The Map works on a single server but won’t share state across serverless function invocations. That’s the same caveat as any in-memory rate limiter.
Testing Procedures with createCallerFactory
createCallerFactory lets you call procedures directly without an HTTP server. No mocking fetch handlers. No test servers. Just the procedure logic.
// tests/email-router.test.ts
import { createCallerFactory, router } from "@/server/trpc";
import { emailRouter } from "@/server/routers/email";
import { vi, describe, it, expect, beforeEach } from "vitest";
const testRouter = router({ email: emailRouter });
const createCaller = createCallerFactory(testRouter);
describe("email.verify", () => {
beforeEach(() => {
vi.resetAllMocks();
});
it("returns deliverable status for a valid email", async () => {
vi.spyOn(global, "fetch").mockResolvedValueOnce({
ok: true,
json: async () => ({
email: "[email protected]",
result: "deliverable",
is_disposable: false,
is_catch_all: false,
}),
} as Response);
const caller = createCaller({});
const result = await caller.email.verify({ email: "[email protected]" });
expect(result.status).toBe("deliverable");
expect(result.disposable).toBe(false);
});
it("returns unknown status when the API times out", async () => {
vi.spyOn(global, "fetch").mockRejectedValueOnce(new Error("timeout"));
const caller = createCaller({});
const result = await caller.email.verify({ email: "[email protected]" });
expect(result.status).toBe("unknown");
});
it("throws TRPC validation error for malformed email input", async () => {
const caller = createCaller({});
await expect(
caller.email.verify({ email: "not-an-email" })
).rejects.toThrow("Enter a valid email address");
});
});
The createCaller({}) call passes the context (empty here, since there’s no auth on the public procedure). Middleware runs. Input validation runs. The actual procedure body runs. You’re testing the real thing, not a mock.
The input validation test doesn’t need a mocked fetch at all. Zod rejects "not-an-email" before the procedure body even starts. That test is fast. Under 5ms.
What the Schema Does and Doesn’t Catch
Developers sometimes misunderstand the layers here. Let’s be concrete.
Zod’s .email() runs a regex. It catches user@ and missing-dot-domain and the empty string. It passes [email protected], [email protected], and [email protected] where the mailbox doesn’t exist. The regex can’t know if a domain resolves or if a mailbox is real.
MailCop’s three-layer verification catches what Zod misses. Syntax check (similar to Zod but more thorough), MX record lookup (does the domain accept mail?), and SMTP check (does the mailbox exist on that server?). The email validation API guide has the full breakdown of what each layer adds and what it costs in latency.
For catch-all domains, the catchAll: true flag in the response tells you the server accepts mail for any address at that domain. Whether [email protected] is a real person you can’t know. The SvelteKit email validation post covers how to handle catch-alls in a form context.
For disposable addresses, MailCop checks against a blocklist of known temporary email providers. Over 180,000 domains. Updated continuously. Static regex patterns can’t keep up. Not even close.
The Type Safety Guarantee
Here’s what you actually get with this setup.
The schema is defined once in lib/schemas/email-verification.ts. The tRPC procedure imports it for .input() and .output() validation. The client imports it for the React Hook Form resolver. Three consumers, one source of truth.
If you change status: z.enum(["deliverable", "undeliverable", "risky", "unknown"]) to add a fifth value like "invalid", every switch statement and comparison in the codebase that doesn’t handle "invalid" will show a TypeScript error. Before you run the app. Before you deploy. Before users see it.
That’s the guarantee. Not that nothing will ever go wrong. That when something changes, you find out immediately, with a clear error message pointing to the exact line. That’s worth a lot more than hoping your tests cover every payload variation.
Compare that to a REST API with hand-written types. You change the server. You update the OpenAPI spec. You regenerate the client types. You notice the regeneration missed a field. You fix it. Three steps and two opportunities to drift. With tRPC, the router type IS the client type. No drift possible.
Want to validate emails in a webhook-driven architecture? The same shared schema works. See the email validation webhook patterns post for how to structure async flows where results come back minutes after submission.
A Note on Output Validation in Production
.output() on procedures adds a runtime Zod parse on the return value. This is good for development and testing. It guarantees your procedure returns exactly what you promised.
In production under high load, parsing every response adds CPU overhead. Small but real. Some teams disable .output() validation in production and rely on TypeScript alone after the schema is proven stable. Others keep it on permanently as a guard against subtle API drift.
Neither is wrong. The important thing: make the decision intentionally, not by accident.