If your JSON shape comes from a source you control — your own database row, a known config file, a hard-coded fixture — a plain TypeScript interface is enough. Compile-time checks catch mismatches before they ship and the runtime cost is zero. The interface mode is the right pick for cases like a
typed package.json reader
or an internal config loader. Use type when you want unions, intersections, or computed types instead of nominal interfaces — most of the time the difference is taste, not behaviour.
If the JSON crosses a network boundary — a third-party webhook, an LLM response, an inbound HTTP body — a TypeScript type alone is a lie, because the compiler cannot catch a missing field at runtime. That is where Zod and Valibot earn their keep: parse the payload, throw on shape mismatch, and the rest of your code can trust the result. Reach for Zod on a verified Stripe checkout.session.completed handler, a Notion page payload your automation must trust, or a package.json validator inside a build step. Reach for Valibot when bundle size or cold start matters — edge runtimes like Cloudflare Workers and Vercel Edge have a hard ceiling, and Valibot tree-shakes to roughly a fifth of Zod's footprint. A GitHub repository payload on a Worker or a PostHog capture event on the edge is the canonical Valibot moment.
Real JSON is messier than examples. Arrays of objects merge into one shape — fields missing from any element become optional, so the result is a true superset of every item. That is what makes a multi-choice OpenAI chat completion or an API Gateway v2 Lambda event with mixed request contexts produce one stable type instead of a noisy union. Mixed primitive types in the same key collapse into a deduped union; a Twilio SMS webhook with optional media URLs falls into this path. Unsafe identifier keys (dashes, dots, leading digits) are quoted as string literals instead of bare identifiers — important for nested Discord embed fields and JSON:API resource keys. Output is deterministic: the same input always produces the same names and ordering, which keeps generated types diff-friendly when you commit them.