Validation with Zod and Trusted Domain Types
Validation with Zod and trusted domain types gives a Next.js database application a disciplined path from messy external input to values that repository code is allowed to persist. The outcome is simple: route handlers, server actions, and form endpoints accept unknown data, Zod proves its shape at runtime, and TypeScript brands the parsed result so lower layers cannot accidentally accept unvalidated strings or objects.
This lesson belongs in the database application section because validation is most valuable immediately before data becomes durable. A React form can prevent many mistakes, but the database write path is the authority. By the end, you should be able to place Zod at the right boundary, model trusted identifiers and commands, explain what TypeScript can and cannot enforce, and troubleshoot common validation failures in a Next.js App Router feature.
Purpose and Outcome
Zod is a runtime schema library. TypeScript checks source code before it runs; Zod checks values while the application is handling a request. That distinction matters in Next.js because user input crosses into server code through FormData, JSON request bodies, search params, headers, cookies, and third-party callbacks. All of those values should be treated as unknown until a schema parses them.
A trusted domain type is a type that cannot be constructed casually. In TypeScript it is commonly represented with a brand: an ordinary runtime value such as a string, plus a compile-time marker that says it has passed a named rule. For example, any string can be typed as string, but only a lowercased validated domain can be typed as TrustedEmail. The database layer can then require TrustedEmail instead of string.
How Zod Parsing Works
A Zod schema is a parser and a validator. It starts with an input value, applies type checks, transformations, refinements, defaults, and object policies, then returns either parsed data or a structured error. parse throws on failure. safeParse returns a discriminated result with success, data, and error. In request handlers and server actions, safeParse is often easier because validation failure is an expected user-facing outcome rather than an exceptional infrastructure failure.
Zod object schemas also decide what to do with properties not declared in the schema. The default behavior is to return the declared shape and ignore extra keys. strict rejects extra keys. passthrough keeps them. For database commands, strict or default stripping is usually preferable to passthrough because it prevents a client from smuggling fields such as role, ownerId, or isPublished into a write command.
Transformations and refinements run in a pipeline. A string can be trimmed, lowercased, checked for a minimum length, and then branded. The brand does not change the JavaScript value. It changes what TypeScript will allow later. That is both useful and limited: the brand protects code paths during compilation, but it does not survive JSON serialization as a runtime marker. If a branded value leaves the process and comes back, parse it again.
API Anatomy
The usual shape is Schema.safeParse(input) at the boundary, a small mapper from Zod errors to form or API errors, and a domain function that returns trusted values. Keep the schema close to the boundary when it describes transport details, such as FormData strings. Keep reusable domain parsers near the model when they express business rules, such as a slug format or money range.
import { z } from "zod";
const EmailSchema = z.string().trim().toLowerCase().email().brand<"TrustedEmail">();
type TrustedEmail = z.infer<typeof EmailSchema>;
function parseEmail(input: unknown): TrustedEmail {
return EmailSchema.parse(input);
}
const email = parseEmail(" OWNER@example.COM ");
console.log(email);
This first example parses an unknown value into a branded trusted email. The deterministic output is owner@example.com. The trim and lowercase transformations happen before the brand is applied. Repository code can now demand TrustedEmail and refuse a plain string at compile time.
Example 1: A Form Command
Next.js server actions often receive FormData. FormData.get returns FormDataEntryValue | null, so the schema should accept the raw boundary value and produce the command your application actually wants.
import { z } from "zod";
const CreateProjectFormSchema = z.object({
name: z.string().trim().min(3).max(80),
slug: z.string().trim().toLowerCase().regex(/^[a-z0-9-]+$/).max(80),
}).strict();
type CreateProjectCommand = z.infer<typeof CreateProjectFormSchema>;
function parseCreateProjectForm(formData: FormData): CreateProjectCommand {
const raw = {
name: formData.get("name"),
slug: formData.get("slug"),
};
return CreateProjectFormSchema.parse(raw);
}
If the submitted name is Roadmap and the slug is Q4-Launch , the parsed command contains name: Roadmap and slug: q4-launch. If the slug contains a space, parsing fails before database code runs. The strict call rejects extra fields, which is useful when a browser request includes unexpected values.
Example 2: Cross-Field Rules
Some rules depend on multiple fields. A course enrollment may allow either a trial or a paid seat, but not both. Use superRefine when you need to attach a custom issue to a specific field.
import { z } from "zod";
const EnrollmentSchema = z.object({
courseId: z.string().uuid(),
trialDays: z.coerce.number().int().min(0).max(30),
paymentIntentId: z.string().trim().min(10).optional(),
}).superRefine((value, ctx) => {
if (value.trialDays > 0 && value.paymentIntentId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["paymentIntentId"],
message: "Choose trial access or paid access, not both.",
});
}
});
const result = EnrollmentSchema.safeParse({
courseId: "550e8400-e29b-41d4-a716-446655440000",
trialDays: "14",
paymentIntentId: "pi_1234567890",
});
console.log(result.success);
The expected output is false. z.coerce.number handles the common form case where numbers arrive as strings, then superRefine rejects the inconsistent command. This is better than allowing the repository to infer intent from two conflicting columns.
Example 3: Trusted IDs in Repositories
Trusted domain types become most useful at the persistence boundary. The repository should not accept arbitrary strings for important identifiers. It should accept values returned by parsers that know the format and ownership checks required by the domain.
import { z } from "zod";
const ProjectIdSchema = z.string().uuid().brand<"ProjectId">();
const UserIdSchema = z.string().uuid().brand<"UserId">();
type ProjectId = z.infer<typeof ProjectIdSchema>;
type UserId = z.infer<typeof UserIdSchema>;
type ProjectPatch = Readonly<{
projectId: ProjectId;
actorId: UserId;
name: string;
}>;
function parseProjectPatch(input: unknown): ProjectPatch {
const PatchSchema = z.object({
projectId: ProjectIdSchema,
actorId: UserIdSchema,
name: z.string().trim().min(3).max(80),
}).strict();
return Object.freeze(PatchSchema.parse(input));
}
async function updateProjectName(command: ProjectPatch) {
return { updated: true, projectId: command.projectId, name: command.name };
}
The branded ProjectId and UserId are both strings at runtime, but TypeScript treats them as different trusted types. That prevents accidentally passing a user id where a project id is required. The repository still needs authorization, such as verifying that actorId can modify projectId, because validation proves shape and format, not permission.
Design Choices and Trade-Offs
One design choice is where to parse. Parse at every external boundary: server actions, route handlers, webhook handlers, and background job inputs. Avoid parsing only inside React components because client-side checks are optional from an attacker’s perspective. Also avoid parsing only in the repository if doing so makes every caller handle transport-specific errors.
A second choice is how much to transform. Trimming and lowercasing email addresses or slugs is usually helpful because the canonical stored value is obvious. Transforming money, dates, or locale-dependent values deserves more care. For money, prefer integer minor units or a decimal library strategy instead of a JavaScript floating-point transformation hidden in a schema.
A third choice is whether to use brands everywhere. Brands are valuable for ids, slugs, emails, and commands that must not be confused. They can become noisy for trivial fields. Use them where mixing values would create a real bug, especially across database tables or tenant boundaries.
Failure Modes and Troubleshooting
Symptom: a valid-looking form always returns a validation error for a number. Cause: the schema uses z.number(), but FormData supplies strings. Diagnose: log the value type without logging sensitive payloads, or inspect typeof formData.get('field'). Correction: use z.coerce.number() with integer and range checks, or manually convert before parsing.
Symptom: TypeScript says a plain string is not assignable to ProjectId. Cause: the repository requires a branded type, and the caller skipped the parser. Diagnose: follow the value back to its boundary and find whether it came from params, JSON, or the database. Correction: parse route params with ProjectIdSchema, or type database rows with trusted values only after they have been selected from trusted columns.
Symptom: a client can set a protected field such as ownerId. Cause: the command schema accepts more fields than intended or the server spreads raw input into a database write. Diagnose: send a request containing an unexpected field and inspect the parsed command. Correction: build a strict allowlisted command object and set privileged fields from the authenticated session, not from the request body.
Symptom: Zod errors reach users as generic 500 responses. Cause: parse is throwing inside a route handler without mapping the error. Diagnose: check logs for ZodError and confirm whether the boundary uses parse or safeParse. Correction: use safeParse for expected bad input and return field-level errors or a 400 response.
Security, Performance, and Reliability
Zod validation reduces injection and mass-assignment risk by narrowing accepted shapes before persistence. It does not replace parameterized queries, authorization checks, unique constraints, or transaction rules. Treat validation as the first gate in the write path, then enforce identity and database invariants near the operation that changes state.
Performance is usually acceptable for request-sized payloads, but schemas still execute code. Avoid running large arrays through expensive refinements on every render. Parse once at the boundary, pass the trusted value inward, and test realistic payload sizes for import screens or bulk admin tools. Reliability improves when validation errors are deterministic and structured: a bad request should fail the same way without partially writing data.
Hands-On Lab
Prerequisites: a Next.js App Router project with TypeScript enabled and Zod installed. You should have one server action or route handler that creates a database-backed record, even if the database is a local development database.
- Create a domain file named
project-domain.tswith branded schemas forProjectId,UserId, and a strictCreateProjectCommand. - In the server action, read raw
FormDatainto a plain object. Do not spread the raw object into the database call. - Call
safeParse. If it fails, return a typed validation state to the page or return HTTP 400 from a route handler. - Set trusted fields such as
actorIdfrom the authenticated session after validation. Do not accept them from the form. - Change the repository signature so it accepts only the parsed command or branded ids.
- Add one test for a valid command, one for an invalid slug, and one proving an extra
ownerIdfield is rejected or stripped before persistence.
Verification: submit a valid form and confirm that the row contains trimmed canonical values. Submit a slug with a space and confirm that no row is created. Submit an extra protected field with browser dev tools or a request client and confirm that it is not persisted. Run TypeScript and verify that a plain string cannot be passed to a repository method requiring a branded id.
Cleanup: delete the test rows created during the lab. If you changed an existing repository signature, keep the stricter signature and update callers through parsers instead of widening the type back to string.
Assessment Exercises
- A route has
params.projectIdas a string. Design the smallest parser and repository signature that prevents mixing project ids and user ids. - Explain why a Zod-branded value should be parsed again after it returns from a JSON API, even if the sending service used the same TypeScript type.
- Given a form with
price,currency, anddiscountCode, decide which rules belong in Zod and which still belong in the database or service layer. - Write a failure test showing that an attacker cannot add
isAdminorownerIdto a create command. - Compare
parseandsafeParsefor a public route handler. Which one gives the clearer user and operational behavior?
Summary
Zod handles runtime uncertainty; trusted domain types carry the result into the rest of the Next.js database application. Use schemas at server boundaries, transform only when canonical behavior is clear, brand values that are dangerous to mix, and keep authorization and database constraints in place. The strongest design is a narrow path: unknown input enters, Zod parses it, trusted commands move inward, and persistence code accepts only values that have crossed that path.
