Skip to content
Back to blog

A misplaced .optional() rejected every lead our ads paid for

One Zod schema had .optional() on the outer preprocess wrapper instead of the inner array. It 400'd every form submission without a file attachment — including all 61 clicks we paid for.

zod typescript debugging forms

We ran a small search campaign to our project intake form. It bought 945 impressions and 61 clicks for $129.05.

The conversion action wired to that form recorded zero submissions. Not a low number — zero, across every click. The campaign’s only conversion in its entire lifetime was a phone call from someone who searched “website creation” and rang us instead of using the form.

The form was not broken in any way a person would notice. It rendered, it validated, the button worked. It returned HTTP 400 to every submission that did not include a file attachment, with a single-word error: Required.

The cause was one method call on the wrong side of a closing parenthesis.

The bug

The intake form lets you attach files. Uploads go to a private bucket before submit, and the browser collects their metadata into one hidden field as a JSON string. So the schema has to accept a string and turn it into an array. That is what z.preprocess is for.

Here is the shape that was wrong:

// BROKEN
attachments: z
  .preprocess(
    (val) => {
      if (typeof val !== 'string') return val;
      const trimmed = val.trim();
      if (trimmed === '') return undefined;   // nobody attached anything
      try { return JSON.parse(trimmed); } catch { return val; }
    },
    z.array(attachmentSchema).max(10),        // <-- not optional
  )
  .optional();                                // <-- optional out here instead

And the fix:

// FIXED
attachments: z
  .preprocess(
    (val) => { /* …same… */ },
    z.array(attachmentSchema).max(10).optional(),  // <-- optional on the INNER schema
  )
  .optional();

That is the whole diff. .optional() moved inside one set of parentheses.

Why it fails, precisely

.optional() on a schema means “this passes if the incoming value is undefined.” The important word is incoming.

The form always posts attachments, because it is a hidden field that is always present in the DOM. When nobody attaches a file, the posted value is the empty string, not undefined.

So on the broken version:

  1. The value arriving is ''.
  2. The outer .optional() checks for undefined. '' is not undefined, so it does not short-circuit.
  3. preprocess runs. It sees an empty string and returns undefined — correctly, that is what it is for.
  4. That undefined is handed to z.array(...), which is not optional.
  5. A non-optional array rejects undefined with the message Required.

The outer .optional() was guarding a door nobody came through. The only value that would have triggered it was undefined, which the form never sends.

Attach a file and it worked perfectly: preprocess got a real JSON string, returned a real array, and the array schema was satisfied. The bug was invisible to anyone who tested with an attachment — which is what you naturally do when you build a file-upload feature.

Why it survived

Three things kept it alive longer than it should have.

The error message says nothing. A bare Required with no field path, arriving as a 400, reads like a generic validation failure. It does not point at attachments, and it certainly does not suggest that the field is optional and being asked for anyway.

The happy path exercised the wrong branch. Every manual test of the attachment feature attached something. The failing case was the absence of input, and absence is the thing nobody remembers to test.

Nothing downstream complained. The 400 was returned honestly, the front end reported a generic failure, and no exception was thrown. There was no error to notice — only leads that never arrived, which looks exactly like leads that never came.

The fix shipped as 9c37603, merged in PR #62.

What we changed about how we test

The general rule we took from this:

At a trust boundary, assert on the absent case, not just the present one.

Every optional field has two paths and only one of them is the interesting one. For anything that accepts input from outside the system, the test that matters is the one where the field is missing, empty, or empty-ish — '', null, [], 'null', 'undefined' as a literal string.

Concretely, for a schema like this, the test suite now covers all four:

parse({ attachments: '' });            // empty  → must pass
parse({ attachments: undefined });     // absent → must pass
parse({ attachments: '[{…}]' });       // real   → must pass
parse({ attachments: 'not json' });    // junk   → must fail

The first case is the one that was broken in production, and it is the one that is easiest to leave out, because an empty string does not feel like a value worth writing a test for.

Two wider lessons

A silent 400 is worse than a crash. An exception gets logged, alerted on, and fixed. A well-formed rejection of legitimate input produces no error anywhere — it produces an absence, and absences do not page anyone. If you are spending money to send people to a form, the form’s success rate deserves its own alarm. Zero submissions across 61 paid clicks should have raised something on its own, without anyone reading a Zod schema.

Do not judge a channel until you have verified the whole funnel. The obvious read on 61 clicks and no submissions is that the traffic was bad or the offer was wrong. Both are plausible and both are wrong here. We could easily have concluded that search advertising does not work for us, turned the campaign off, and kept the actual defect. Verify that the form works end to end before you draw a conclusion about the ads — and verify it by submitting it exactly the way a stranger would, which for most people means without attaching anything.

The $129.05 is the cheap part. Nearly losing a correct read on a marketing channel was the expensive part.

Built right. Kept running.

Have a project, or a stalled build you need rescued? Tell us what you need and we'll scope it — fixed price, clear timeline, usually same day.