cswork / libraryOpen practice workspace

Articles

Zod Interview Questions: Runtime Validation, Coercion, and Type Boundaries

Practise Zod 4 interview questions on runtime validation, exact Boolean coercion, transformed types, defaults, unknown keys, and async dependency failures.

Zod interview questions are about the boundary between an untrusted value and a usable application value. A TypeScript annotation cannot prove that an HTTP query, configuration file, or webhook payload has the expected runtime shape. A schema can validate and transform that input—but it does not authorize the resulting action.

The most revealing mistakes involve coercion and type boundaries: treating "false" as false through JavaScript Boolean conversion, accepting an empty string as zero, confusing a transformed input type with its output, or reporting a backend outage as invalid user input. This article uses Zod 4 and an original bounded query parser to make those mistakes observable.

For related practice, start with Debug Validation Error Aggregation. The goal is not merely to obtain a passing parse; it is to explain which values cross the boundary, what they become, and what the application still must check.

What does Zod prove that TypeScript does not?

TypeScript describes types during development, but external data arrives at runtime. Casting a request body to User does not inspect it. The TypeScript narrowing documentation explains how runtime checks inform static reasoning; a schema library provides a structured way to perform those checks.

Zod's basics documentation distinguishes parsing from safe parsing and input types from transformed output types. A successful parse gives the schema's output, which may differ from the raw value. z.infer describes that output; z.input describes the accepted input type before transformations.

Do not describe this as “Zod makes all data safe.” A valid account identifier can still belong to another tenant. A valid URL can still point to an inappropriate destination. A valid integer can still exceed a user's authorized quota. Shape and value validation reduce uncertainty at one boundary; policy decisions remain separate.

Also scope the validation to the correct layer. Check transport size limits before allowing an arbitrarily large payload into expensive parsing. Define whether duplicate query keys, array values, or missing fields are possible in your HTTP framework. The schema should match the adapter's actual raw representation, not an imagined one.

Why coercion is not semantic interpretation

Zod's API reference documents primitive coercion through JavaScript conversion behavior. That can be useful, but the conversion is not a domain-specific parser. Boolean("false") is true because a nonempty string is truthy. Number("") is zero, and Number(null) is also zero. Those results may violate the request contract even though the conversions are valid JavaScript.

Before coercing, decide the allowed vocabulary. For an HTTP flag, does the API accept only true and false, or also 1, 0, yes, and no? Is case significant? Is whitespace allowed? Explicit answers prevent one endpoint from accepting values another endpoint rejects.

Zod 4's stringbool provides string-to-Boolean parsing with configurable accepted strings. Its defaults are broader than a strict two-word API contract, so configure the vocabulary when that distinction matters. Do not use a numeric or Boolean fallback to make malformed security-sensitive input look valid.

Similarly, choose the numeric grammar before conversion. A limit parameter may accept decimal digits but reject exponent notation, signs, fractions, and hexadecimal forms. Bounding the string length and enforcing that grammar before Number makes accepted values easier to explain. A final range check then enforces the application's allowed limit.

Build an explicit query boundary

Suppose an endpoint accepts limit and includeArchived as strings. Missing limit means 20; otherwise it must be one to three decimal digits representing an integer from 1 to 100. Missing includeArchived means false; otherwise it must be exactly lowercase true or false. Unknown keys are rejected.

This is an original contract, not a universal pagination convention. The HTTP adapter passes an object containing raw strings or omitted properties. If the adapter can produce arrays for repeated keys, reject those values or normalize them according to a separately documented rule.

import * as z from "zod";

export const Query = z.strictObject({
  limit: z.string().trim().regex(/^[0-9]{1,3}$/)
    .transform(Number)
    .pipe(z.number().int().min(1).max(100))
    .prefault("20"),
  includeArchived: z.stringbool({
    truthy: ["true"],
    falsy: ["false"],
    case: "sensitive",
  }).default(false),
});

type RawQuery = z.input<typeof Query>;
type ParsedQuery = z.output<typeof Query>;

const raw: RawQuery = { limit: "25", includeArchived: "false" };
const parsed: ParsedQuery = Query.parse(raw);
console.assert(parsed.limit === 25 && parsed.includeArchived === false);

The schema trims the numeric string by choice but does not trim the Boolean string. Therefore " 25 " is accepted, while " true " is rejected. If the interviewer requests symmetric whitespace handling, make that a deliberate schema change and add a test. Do not call the difference accidental library behavior.

The bounded regex establishes syntax, not range. "000" matches the grammar but transforms to zero and fails the minimum. "100" succeeds; "101" fails. If leading zeros are disallowed by the API contract, use a different grammar rather than assume conversion preserves the original spelling.

Distinguish input, output, default, and prefault

In this parser, a supplied limit begins as a string and becomes a number. A supplied flag begins as a string and becomes a Boolean. The parsed object is what downstream logic should consume. Passing the original object onward defeats the boundary even if a separate parse succeeded.

Zod 4's migration and feature documentation and API reference distinguish defaulting from prefaulting. A default can supply an output directly when the input is undefined; a prefault supplies an input that continues through parsing. That matters when a schema transforms its value.

Consider a string schema that trims and uppercases. A default of " guest " is returned as that output value on undefined input, while a prefault of " guest " passes through the transforms and becomes "GUEST". Do not assume every fallback value is normalized automatically.

Our numeric limit uses .prefault("20") so the same grammar, conversion, and range checks apply to the missing-field replacement. The Boolean schema uses .default(false) because false is already a valid output. Reversing those choices without understanding the types can create either a type error or different runtime semantics.

Optional, nullable, and nullish answer different questions. Optional accepts undefined; nullable accepts null; nullish accepts both. None means that an empty string becomes absent. If a form sends "" for an optional field, decide explicitly whether it is an invalid value or should be normalized to undefined.

Test the adversarial matrix, not just a happy path

The following expected results belong to the exact query contract above. Keeping the raw value visible makes it harder to confuse JavaScript conversion with accepted API semantics.

Raw inputExpected resultBoundary being checked
{}{limit: 20, includeArchived: false}Missing-field replacement.
{limit: "25", includeArchived: "false"}Number 25 and Boolean falseTransformed output, not raw strings.
{limit: ""} or {limit: null}RejectEmpty and null are not absent.
{limit: "1e2"}RejectExponent notation is outside the grammar.
{limit: "0"} or {limit: "101"}RejectInteger range after conversion.
{includeArchived: "False"}RejectExact lowercase vocabulary.
{includeArchived: false}RejectAdapter contract requires strings when present.
{role: "admin"}RejectUnknown-field policy is explicit.

The local fixture for this article runs these cases with Zod 4.6.5 and checks the TypeScript input/output distinction. Version pinning makes the observed result reproducible; it does not promise that every application uses the same version or contract.

For static checks, assign a raw string to the input type and confirm that assigning it to the parsed numeric output type fails compilation. Also confirm that a Boolean value is not a valid present raw flag under this schema. Runtime success and static type checks answer related but different questions.

Choose the unknown-key policy deliberately

Zod's regular object schema strips unrecognized keys by default; strictObject rejects them, and looseObject retains them. The important interview answer is why the chosen behavior fits the boundary, not which option sounds most restrictive.

For a public request contract, rejection can make client mistakes visible. For a versioned integration, stripping additional fields may support a deliberate compatibility policy. For an internal extension payload, retention may be necessary—but downstream code must still know which retained values it can trust.

Our query rejects role because the endpoint advertises exactly two parameters. Even if it retained that field, the application must not authorize privileged behavior from a client-supplied role. Validation and authorization are distinct regardless of the object mode.

Nested objects require the same deliberate policy at the relevant depth. Do not assume an outer strict object automatically enforces every desired rule inside arbitrary nested data. Build schemas for those nested boundaries and include a deeply nested unexpected-key case in the test suite.

Separate invalid input from unavailable dependencies

Some checks require asynchronous work: whether a referenced account exists, whether a code has expired, or whether a service recognizes a resource. Async refinements require an async parse method such as parseAsync or safeParseAsync. Calling the synchronous path does not make the network operation synchronous.

More importantly, “the account does not exist” differs from “the account service timed out.” The first may be a validation result; the second is an operational failure. Do not catch every exception and relabel it as an invalid account. That hides outages and produces misleading client feedback.

A useful design separates structural parsing from dependency lookup. Parse the account identifier, call the backend, then map a known not-found result to a domain error while propagating or translating unavailability separately. If using an async refinement, ensure unexpected exceptions remain distinguishable from ordinary validation issues; safeParseAsync is not a blanket exception-swallowing mechanism.

Even a successful existence check can become stale before a write. Use appropriate database constraints or transaction semantics for invariants such as uniqueness and ownership at the time of mutation. A schema check cannot eliminate that race by itself.

Return useful errors without leaking the payload

safeParse exposes a success/failure result for expected parsing errors. Branch on that result before reading the parsed data. If parsing succeeds, consume the output object. If it fails, map issues into the API's documented error shape rather than return an uncontrolled exception dump.

Zod's error-formatting documentation provides helpers for different error presentations. Choose a shape that preserves the information the client needs. A nested array path is different from a single field name; flattening everything into a dotted string can create ambiguity when actual keys contain punctuation.

Log enough context to diagnose the contract violation, but avoid recording passwords, tokens, or the entire rejected body by default. A useful log can include a request identifier, schema version, issue code, and safe field path. Public error messages should not reveal backend internals or account-existence information when that conflicts with the product's security policy.

Sources and Further Reading