{"id":338,"date":"2026-06-18T05:01:07","date_gmt":"2026-06-18T05:01:07","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/zod-validation-the-runtime-types-i-stopped-hand-checking\/"},"modified":"2026-06-18T05:01:07","modified_gmt":"2026-06-18T05:01:07","slug":"zod-validation-the-runtime-types-i-stopped-hand-checking","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/zod-validation-the-runtime-types-i-stopped-hand-checking\/","title":{"rendered":"Zod Validation: The Runtime Types I Stopped Hand-Checking"},"content":{"rendered":"<p>Confession: for years I wrote TypeScript like the data coming into my app would always match the types I&rsquo;d declared. I&rsquo;d type an API response as <code>User<\/code>, cast the JSON, and move on with my day feeling very safe. Then a backend changed a field from a number to a string, my &ldquo;fully typed&rdquo; code sailed straight past it, and I spent an afternoon chasing a crash that TypeScript had cheerfully promised me was impossible.<\/p>\n<p>The thing nobody tells you early on is that TypeScript types vanish at runtime. They&rsquo;re a compile-time story. The moment real data shows up from a form, an API, or a file, your beautiful types are gone and you&rsquo;re back to hoping. Zod is the tool that finally fixed this for me, and I went from sprinkling it in one or two spots to reaching for it any time data crosses a boundary. Here&rsquo;s how I actually use it, and where I hold back.<\/p>\n<h2 id=\"the-lie-i-was-telling-with-as\">The lie I was telling with <code>as<\/code><\/h2>\n<p>Here&rsquo;s the pattern I used to write, and it&rsquo;s the pattern I see in most codebases that &ldquo;use TypeScript properly&rdquo;:<\/p>\n<pre><code class=\"language-ts\">type User = { id: number; name: string; email: string };\n\nasync function getUser(id: number): Promise&lt;User&gt; {\n  const res = await fetch(`\/api\/users\/${id}`);\n  return (await res.json()) as User;  \/\/ a promise I cannot keep\n}\n<\/code><\/pre>\n<p>That <code>as User<\/code> is me telling the compiler to trust me. The compiler does, because that&rsquo;s what <code>as<\/code> means: stop checking, I&rsquo;ve got this. Except I don&rsquo;t got this. The JSON could be missing <code>email<\/code>, could have <code>id<\/code> as a string, could be an error object with a completely different shape. TypeScript shrugs and types it <code>User<\/code> anyway, and the bug surfaces three function calls later where it&rsquo;s much harder to trace. The official handbook is upfront that <a href=\"https:\/\/www.typescriptlang.org\/docs\/handbook\/2\/everyday-types.html#type-assertions\" rel=\"nofollow noopener\" target=\"_blank\">type assertions<\/a> are an escape hatch, not a guarantee.<\/p>\n<h2 id=\"what-zod-does-instead\">What Zod does instead<\/h2>\n<p>Zod lets you describe the shape once as a schema, then validate real data against it at runtime. If the data fits, you get a typed value back. If it doesn&rsquo;t, you get a clear error instead of a silent lie.<\/p>\n<pre><code class=\"language-ts\">import { z } from &quot;zod&quot;;\n\nconst User = z.object({\n  id: z.number(),\n  name: z.string(),\n  email: z.string().email(),\n});\n\nasync function getUser(id: number) {\n  const res = await fetch(`\/api\/users\/${id}`);\n  return User.parse(await res.json());  \/\/ throws loudly if the shape is wrong\n}\n<\/code><\/pre>\n<p>Now if the backend sends <code>id<\/code> as a string, <code>parse<\/code> throws right at the boundary, with a message telling you which field was wrong and why. The error lands where the bad data entered, not three layers deep. The full API is documented at <a href=\"https:\/\/zod.dev\" rel=\"nofollow noopener\" target=\"_blank\">zod.dev<\/a>, and it&rsquo;s one of those docs sites where reading the first page genuinely changes how you write code.<\/p>\n<h2 id=\"the-part-that-sold-me-types-for-free\">The part that sold me: types for free<\/h2>\n<p>I assumed Zod meant maintaining two things, a schema and a TypeScript type, kept in sync by hand. That sounded miserable. It turns out you write the schema and let Zod hand you the type:<\/p>\n<pre><code class=\"language-ts\">const User = z.object({\n  id: z.number(),\n  name: z.string(),\n  email: z.string().email(),\n});\n\ntype User = z.infer&lt;typeof User&gt;;\n\/\/ { id: number; name: string; email: string }\n<\/code><\/pre>\n<p>One source of truth. Change the schema and the type updates automatically, because the type is derived from the schema rather than declared alongside it. This is the same instinct I wrote about in my post on <a href=\"https:\/\/abrarqasim.com\/blog\/typescript-utility-types-i-actually-use-and-the-ones-i-skip\" rel=\"noopener\">TypeScript utility types<\/a>: let the compiler derive things instead of copying them by hand. Zod just extends that idea past the compile-time wall.<\/p>\n<h2 id=\"forms-are-where-it-pays-rent\">Forms are where it pays rent<\/h2>\n<p>API responses are the obvious case. Forms are where Zod quietly saves me the most time, because form data is the messiest data in any app. Everything arrives as a string, half of it is optional, and users will type things you didn&rsquo;t plan for.<\/p>\n<pre><code class=\"language-ts\">const Signup = z.object({\n  email: z.string().email(),\n  age: z.coerce.number().min(18, &quot;Must be 18 or older&quot;),\n  website: z.string().url().optional(),\n});\n\nconst result = Signup.safeParse(formData);\nif (!result.success) {\n  return result.error.flatten().fieldErrors;  \/\/ ready to render next to inputs\n}\nconst clean = result.data;  \/\/ fully typed and trustworthy\n<\/code><\/pre>\n<p>Two things to notice. <code>z.coerce.number()<\/code> turns the string <code>\"21\"<\/code> into the number <code>21<\/code> before validating, which handles the everything-is-a-string problem. And <code>safeParse<\/code> returns a result object instead of throwing, which is what you want in a form handler where an error is a normal outcome rather than a crash. I lean on this hard in the form-heavy work I describe over on my <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">portfolio<\/a>, where validation rules tend to multiply faster than anyone expects.<\/p>\n<h2 id=\"parse-vs-safeparse-and-not-overdoing-it\">parse vs safeParse, and not overdoing it<\/h2>\n<p>I get asked which to use. My rule is simple. Use <code>parse<\/code> when bad data means something is genuinely broken and crashing loudly is correct, like an API response that violates a contract. Use <code>safeParse<\/code> when bad data is expected and you want to handle it gracefully, like user input. Reaching for try\/catch around <code>parse<\/code> in a form handler is a sign you wanted <code>safeParse<\/code> all along.<\/p>\n<p>The other thing I had to learn is restraint. Zod has a runtime cost, since it actually walks your data and checks it. That cost is trivial for an API response or a form submit. It is not trivial if you validate a 50,000-row dataset inside a tight loop, and I&rsquo;ve watched someone do exactly that and then blame the library. Validate at the edges, where data enters your system, and trust your own types in the interior once it&rsquo;s clean. That single habit is what makes Zod feel free in practice even though it isn&rsquo;t literally free.<\/p>\n<h2 id=\"where-i-still-dont-reach-for-it\">Where I still don&rsquo;t reach for it<\/h2>\n<p>I don&rsquo;t wrap internal function calls in schemas. If both sides of a call are my own typed code that never touched the outside world, TypeScript already has me covered and adding Zod there is just noise. I also don&rsquo;t use it for config I control and ship myself, though I do use it for config that users or other services provide. The mental model that&rsquo;s served me well: Zod guards the border between the typed world I control and the untyped world I don&rsquo;t. Inside the border, plain TypeScript. At the border, a schema.<\/p>\n<h2 id=\"what-to-try-this-week\">What to try this week<\/h2>\n<p>Find the one <code>fetch<\/code> in your codebase whose response you trust the least, probably a third-party API or an internal service that changes often. Write a Zod schema for the response you expect, swap your <code>as SomeType<\/code> for a <code>.parse()<\/code>, and derive the type with <code>z.infer<\/code> so you delete the hand-written interface. Then point it at the real endpoint and see what happens. Either it passes and you&rsquo;ve added a real guarantee for about six lines of code, or it throws and you just found a shape mismatch you didn&rsquo;t know you had. Both outcomes are a win, and both took ten minutes.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>TypeScript types vanish at runtime, which is how bad API and form data slips through. Here&#8217;s how I use Zod to validate at the edges and infer types for free.<\/p>\n","protected":false},"author":2,"featured_media":337,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"TypeScript types vanish at runtime, which is how bad API and form data slips through. Here's how I use Zod to validate at the edges and infer types for free.","rank_math_focus_keyword":"zod validation","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[45,156],"tags":[229,44,367,63,191,190],"class_list":["post-338","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-programming","category-typescript","tag-forms","tag-javascript","tag-type-safety-2","tag-typescript","tag-validation","tag-zod"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/338","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=338"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/338\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/337"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=338"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=338"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=338"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}