Skip to content

Zod

When a Zod schema fails validation, ZodError.issues contains an array of error objects. Each issue has a path property — an array of string and number segments (e.g. ["user", "age"]). Mapping those segments to specific form fields requires comparing them to known field paths. data-path makes that comparison typed and refactor-safe.

import { z } from "zod";
import { path } from "data-path";
const schema = z.object({
user: z.object({
age: z.number().min(18, "Must be at least 18"),
email: z.string().email("Invalid email"),
}),
});
type FormData = z.infer<typeof schema>;
const agePath = path((f: FormData) => f.user.age);
const emailPath = path((f: FormData) => f.user.email);
const result = schema.safeParse({ user: { age: 15, email: "bad" } });
if (!result.success) {
for (const issue of result.error.issues) {
// Compare the Zod segments array directly — no string round-trip.
if (agePath.equals({ segments: issue.path })) {
console.log("Age error:", issue.message);
}
if (emailPath.equals({ segments: issue.path })) {
console.log("Email error:", issue.message);
}
}
}

A Map keyed by stringified segments avoids the dot-collision hazard of path.join("."):

function buildErrorMap(error: z.ZodError): Map<string, string> {
const map = new Map<string, string>();
for (const issue of error.issues) {
// JSON.stringify preserves segment boundaries even when a key contains '.'
map.set(JSON.stringify(issue.path), issue.message);
}
return map;
}
const errors = buildErrorMap(result.error);
// Look up by typed path
const ageError = errors.get(JSON.stringify(agePath.segments)); // "Must be at least 18"
const emailError = errors.get(JSON.stringify(emailPath.segments)); // "Invalid email"

Using agePath.segments as the lookup key keeps the field reference in sync with the schema: if you rename age to birthYear, the path lambda breaks at compile time, and agePath.segments updates automatically.

The same pattern applies when using Zod as a resolver with React Hook Form:

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { path } from "data-path";
const agePath = path((f: FormData) => f.user.age);
function AgeForm() {
const {
register,
formState: { errors },
} = useForm<FormData>({ resolver: zodResolver(schema) });
// errors object is keyed by field name strings
const ageError = agePath.segments.reduce(
(obj: Record<string, unknown>, seg) =>
obj?.[seg] as Record<string, unknown>,
errors as Record<string, unknown>,
) as { message?: string } | undefined;
return (
<form>
<input {...register(agePath.$)} />
{ageError?.message && <span>{ageError.message}</span>}
</form>
);
}

Zod emits numeric indices as real numbers in issue.path (e.g. ["users", 0, "email"]). data-path uses the same string | number segment convention, so the array matches directly — no string conversion required:

const firstUserEmailPath = path((f: FormData) => f.users[0].email);
// Zod issue: { path: ["users", 0, "email"], message: "..." }
firstUserEmailPath.equals({ segments: issue.path }); // true