Well, I have to admit, I used to be a bit of a duplicator myself. You know, defining that TypeScript interface for the API response, and then writing a separate validator just to make sure everything was hunky-dory at runtime. It’s a tale as old as time, really. But you know what they say – the compiler should do the heavy lifting.
Two sources of truth, one inevitable drift. Yep, been there, done that. And let me tell you, that production incident you mentioned – running Node 23.2.0 on the backend and having a “guaranteed” string field come through as null? That’s the kind of thing that’ll make you reevaluate your whole approach.
The TypeScript compiler is a beast, no doubt about it. But it’s got a blindspot when it comes to IO. It just trusts you, you know? “Oh, you say this is a User? Alright, cool, I believe you.” Not the best approach, if you ask me.
Code Generation over Runtime Interpretation

Now, this build-time compilation for contracts – that’s the way to go, if you ask me. Take that JSON Schema, feed it into a compiler, and bam! You’ve got your TypeScript type and a super-optimized validator function, all in one neat little package. No libraries, no runtime overhead. Just good old-fashioned, lightning-fast boolean checks.
And the best part? When you generate the TypeScript type from the same source as the validator, they can’t diverge. You’re practically bulletproof against those pesky “string vs. null” bugs. If the schema changes, the build regenerates everything, and TypeScript will let you know if you didn’t handle that change properly.
Handling the Tricky Stuff: Discriminated Unions
Ah, yes, the bread and butter of modern TypeScript development – discriminated unions. That’s where this approach really shines. Instead of writing that switch statement manually (talk about tedious), the compiler can generate it for you, ensuring that your runtime logic matches your TypeScript narrowing blocks perfectly.

Branded Types and Coercion
And don’t even get me started on those “branded types” – using TypeScript to distinguish between two strings, like UserId and OrderId. Runtime validators usually just see “string,” but with this approach, you can generate those nominal types and keep the runtime check nice and simple.
Is It Worth the Setup?

I know, I know, another build step? Who wants that, right? But trust me, once you get this set up, the mental overhead is practically zero. Edit a schema file, hit save, and boom – your types are updated. No more writing validators, no more debugging “why did Zod say this was okay but TS is angry?” issues.
Sure, for a small side project, Zod is probably fine. But if you’re working on a system where performance matters, or where type safety across network boundaries is critical, this is the way to go. The TypeScript compiler is powerful, but it needs a little help crossing that bridge to runtime. Build that bridge yourself, my friend.
FAQ
Why does TypeScript fail to catch null values in API responses at runtime?
TypeScript has a blind spot when it comes to IO boundaries. The compiler trusts your type annotations without verifying them at runtime, so if you tell it a field is a User, it believes you. That’s how a supposedly guaranteed string field can arrive as null in production on Node 23.2.0, because the compiler never actually checks incoming data against declared types.
How do I generate TypeScript types and validators from a single JSON Schema?
Feed your JSON Schema into a build-time compiler that outputs both the TypeScript type and an optimized validator function together. This produces lightning-fast boolean checks with no runtime library overhead. Because both artifacts come from the same source, they cannot drift apart. When the schema changes, the build regenerates everything and TypeScript flags any unhandled changes in your code.
Is code generation better than Zod for runtime validation in TypeScript?
For small side projects, Zod is probably fine. But for systems where performance matters or type safety across network boundaries is critical, build-time code generation wins. It eliminates runtime overhead, removes the mental burden of writing validators, and avoids debugging mismatches where Zod says data is valid but TypeScript disagrees. Edit a schema, save, and types update automatically.
How does build-time compilation handle discriminated unions and branded types?
For discriminated unions, the compiler generates the switch statement automatically, ensuring runtime logic matches TypeScript’s narrowing blocks perfectly instead of forcing you to write it manually. For branded types like distinguishing UserId from OrderId, code generation produces the nominal TypeScript types while keeping the runtime check simple, since runtime validators normally only see a generic string type.
