TypeScript, Zod, and React Hook Form: The Type Safety Headache Nobody Talks About

Build Logs & Projects\\Jul 24, 2025

You ever fix a bug that makes you wonder if your codebase is gaslighting you? That was me today, wrestling with a so-called “optional” field that TypeScript just refused to let go.

I was refactoring the ContactForm in one of my Next.js projects. Clean little thing, Zod schema for validation, React Hook Form for, well, the form. But then the build broke with those classic TypeScript complaints: “Type ‘string | undefined’ is not assignable to type ‘string'” Yeah, we’ve all seen it. But why now?

The Offending Line

Here’s the setup:

const ContactFormSchema = z.object({
  name: z.string(),
  email: z.string().email(),
  additionalComments: z.string().optional(),
})

In the actual form, I always set a default value for additionalComments:

const { register, handleSubmit } = useForm({
  defaultValues: {
    name: "",
    email: "",
    additionalComments: "",
  }
});

But TypeScript looks at the Zod schema and thinks: “Hang on, this could be undefined.”

What Broke and Why

So, even though the form always handed over a string (sometimes empty, but always a string), TypeScript thought additionalComments might show up as undefined because of that .optional(). React Hook Form and Zod weren’t fighting, they just didn’t agree on what “optional” means in practice.

  • Zod says: “This might be missing. I’ll call it string or undefined.”
  • Form says: “This field is always present, empty or not.”

The mismatch is enough for TypeScript to throw a fit. Suddenly you’re debugging types, not features. I’ve wasted real afternoons on stuff like this.

The Fix (Spoiler: It’s Boringly Simple)

Once I stopped overthinking it, the solution was almost disappointing:

Don’t use .optional() for fields that always have a string, even if it’s blank. Use .default("").

Here’s the actual diff:

- additionalComments: z.string().optional(),
+ additionalComments: z.string().default(""),

Now, the Zod schema, the form’s defaultValues, and TypeScript all agree: This field is always a string.

TypeScript shut up. The build passed. The form behaved exactly the same.

Why This Matters (And Why You’ll Trip Over It Again)

I’ve been at this long enough to know this isn’t just a one-off bug. It’s a pattern. Any time your schema and your form don’t tell the same story, TypeScript will find a way to make you care.

This whole optional vs. default thing is everywhere if you use Zod with React Hook Form. If you always give a field a value, .default("") is your friend. If you really want undefined sometimes, fine, .optional() is there. But don’t mix them unless you like fighting your own tools.

Lesson Nobody Told Me

Don’t trust frameworks to magically “just work” together, no matter how many Medium posts say they do. Type safety is awesome until it isn’t. The trick is making sure your types match the way your form actually works, not the way you wish it did.

That’s pretty much it. The kind of bug you only fix after you’ve been burned at least once. Next time you hit that string | undefined wall, check your schema and your defaults before you blame TypeScript.

That’s the real story. Now, back to building something that actually matters.

Get In Touch

Have a question or just want to say hi? I'd love to hear from you.

Use this form to send me a message, and I aim to respond within 24 hours.

Get In Touch

Need IT consultation? From simple web development to creating and deploying AI agents on modern infrastructure, I'm here to help.

Use this form to send me a message about your project, and I aim to respond within 24 hours.

© 2025All rights reserved
MohitAneja.com