TypeScript's static type system protects us during development, but it completely disappears at runtime. When your backend receives an incoming webhook payload, a microservice response, or user input from a form, writing const user = data as UserProfile offers zero actual safety. If a field is missing, renamed, or formatted unexpectedly, your application crashes downstream with the infamous TypeError: Cannot read properties of null.
This is why Zod has become the gold standard for runtime schema validation in the TypeScript and Next.js ecosystems. However, when converting JSON sample payloads into Zod schemas, automated scripts and naive manual conversions often introduce subtle edge cases. Here are five critical edge cases you must handle to ensure your Zod validation remains rock-solid in production.
1. Nullable vs. Optional vs. Nullish
The most common trap when converting JSON to Zod is conflating null with undefined. In JSON payloads:
- A field can be explicitly set to
null:{"bio": null} - A field can be completely omitted:
{}
In Zod:
-
z.string().optional()allowsstring | undefined(field omitted). If the payload contains{"bio": null}, validation throws aZodError: Expected string, received null. -
z.string().nullable()allowsstring | null. If the field is omitted entirely, validation throwsExpected string, received undefined.
// Incorrect for real-world APIs with mixed null/undefined:
const NaiveUserSchema = z.object({
bio: z.string().optional(), // Fails on {"bio": null}
});
// Resilient schema:
const RobustUserSchema = z.object({
bio: z.string().nullish(), // Accepts string, null, or undefined
});
If your database or upstream API serializes empty database columns as SQL NULL, use .nullish() or .nullable() explicitly rather than a simple .optional().
2. ISO Date Strings and Timestamp Coercion
JSON has no native Date data type. Dates are serialized as ISO 8601 strings (e.g., "2026-08-24T18:00:00.000Z").
If your schema generator simply maps strings to z.string(), invalid date strings like "2026-02-31" will pass through unhindered. If you subsequently pass that string to new Date(str), you end up with Invalid Date NaN values in calculations.
const EventSchema = z.object({
// Validates ISO-8601 format at runtime:
createdAt: z.string().datetime(),
// Or automatically parses the string into a JavaScript Date object:
updatedAt: z.coerce.date(),
});
Using z.coerce.date() transforms the validated string directly into a Date instance, eliminating boilerplate conversion code in your controller layers.
3. Number Precision and Integer Boundaries
JavaScript represents all numbers as 64-bit floating-point (IEEE 754). This creates two major pitfalls when parsing numeric JSON fields:
-
Integer vs. Float: Pagination limits, IDs, and quantities should never accept decimals. If a client sends
?limit=0.5, naive SQL queries or pagination math can produce unexpected results. -
64-bit Integer Overflow: Large integer IDs (such as 64-bit Snowflake IDs or Postgres
BIGINTvalues exceeding $2^{53} - 1$) lose precision when parsed as JavaScript numbers.
const QuerySchema = z.object({
page: z.number().int().positive().default(1),
limit: z.number().int().min(1).max(100).default(20),
// For 64-bit IDs, validate as numeric strings or use BigInt:
entityId: z.string().regex(/^\d+$/),
});
When building schemas from sample JSON containing large IDs or pricing fields, verify whether numbers represent currency cents, integer counters, or large identifiers.
4. Empty and Heterogeneous Array Narrowing
When converting a sample JSON payload where an array is empty (e.g., {"tags": []}), a naive generator will emit z.array(z.unknown()) or z.array(z.any()), completely defeating TypeScript's type inference.
Similarly, APIs frequently return heterogeneous arrays (e.g., ["admin", 42, { "role": "editor" }] or polymorphic event lists).
// Define clear union types or discriminated unions for array elements:
const TagSchema = z.array(z.union([z.string(), z.number()]));
// Inferred TypeScript type: (string | number)[]
type Tags = z.infer<typeof TagSchema>;
Always provide populated sample payloads or explicitly define the element schema instead of accepting z.array(z.unknown()).
5. Object Stripping vs. Strict Mode in Microservices
By default, Zod's z.object() operates in strip mode: any unrecognized properties in the input JSON are silently removed during parsing.
While stripping is generally desirable for protecting internal services against parameter injection, it can cause hard-to-debug data loss if your service is an intermediate proxy or webhook router forwarding payloads.
const WebhookEventSchema = z.object({
id: z.string().uuid(),
event: z.string(),
}).passthrough(); // Retains unmodeled third-party metadata
const StrictConfigSchema = z.object({
apiKey: z.string(),
timeoutMs: z.number().int(),
}).strict(); // Throws ZodError if any typo or unknown property exists
Streamlining Schema Generation
Writing comprehensive Zod schemas for dozens of complex nested endpoints by hand is tedious. Using an in-browser converter like the Nutilz JSON to Zod Generator allows you to instantly transform complex JSON payloads into cleanly structured Zod schemas and TypeScript inferred types without sending sensitive data to external servers.
Once generated, review your schemas for .nullish(), integer constraints, and datetime refinements to ensure total type safety in production.
Top comments (0)