{"title":"Type Safety Stops at Runtime: Test the TypeScript Boundary","excerpt":"A type assertion compiles away, and JSON.parse and Response.json() are typed any, so a payload missing a required role or carrying an unlisted status reaches your domain code unchecked. This guide covers why hand-written type predicates repeat the problem, parsing unknown through a Zod 4 schema into a separate domain type, when to strip or reject undeclared keys, why a missing enum field and an unknown enum value report the same issue code, and a Vitest suite that asserts path, code and a sanitised received value without leaking personal data into failures or test names.","canonicalUrl":"https://automationtester.in/blog/automation-tutorials/typescript-runtime-boundary-validation-testing","category":{"name":"Automation Tutorials","slug":"automation-tutorials"},"tags":["typescript","zod","vitest","runtime-validation","api-testing","contract-testing"],"author":{"name":"Shashank Rawlani","url":"https://shashank.rawlani.com","linkedin":"https://www.linkedin.com/in/shashankrawlani/"},"datePublished":"2026-10-04T13:30:00.000Z","dateModified":"2026-10-14T10:26:50.698Z","coverImage":{"url":"https://ik.imagekit.io/automationtester/automationtester-in/blog/covers/v3/typescript-runtime-boundary-validation-testing.webp","alt":"A dim grey panel on the left holds six horizontal rows: three green pills, one empty dashed orange outline, one solid orange pill and one grey pill. Each green row runs right through a ringed green checkpoint on a tall dashed green vertical gate in the centre and lands as a matching green pill inside a green panel on the right. The dashed outline and the orange pill each end at a crossed-out orange ring on the gate. The grey row peels off downward, passes beneath the bottom of the gate along a faint dotted line and fades into two small grey pills. Above everything, a dashed orange arc leaps from the top of the left panel over the gate and points down into the top of the right panel. A green line leaves the right panel toward the right edge, and faint rules and pills fill the margins."},"contentHtml":"<div class=\"callout callout-info\"><strong>Quick answer:</strong> A type assertion such as <code class=\"language-typescript\">as User</code> is deleted by the compiler and checks nothing at runtime, and both <code class=\"language-typescript\">JSON.parse</code> and <code class=\"language-typescript\">Response.json()</code> are typed as <code class=\"language-typescript\">any</code>, so a network payload can reach your domain code in any shape. Annotate the parsed body as <code class=\"language-typescript\">unknown</code>, run it through an executable schema, and only then convert it to the domain type. Test the boundary with the payloads that actually break it: a missing field, an enum value the contract does not list yet, an extra key, and the wrong top-level type. Assert each rejection by issue path, code and a sanitised description of what was received, never by message text and never by echoing the raw body.</div>\n\n<div class=\"callout callout-warning\"><strong>Zod 4 changed the issue shapes.</strong> Everything below uses the Zod 4 API (<code class=\"language-typescript\">import * as z from \"zod\"</code>, <code class=\"language-typescript\">z.strictObject</code>, <code class=\"language-typescript\">z.iso.datetime()</code>, <code class=\"language-typescript\">z.treeifyError</code>). Issue codes you may have asserted on in Zod 3 tests are not the codes Zod 4 reports; a missing enum field, for example, comes back as <code class=\"language-text\">invalid_value</code>. Every snippet was compiled and every test run on 2026-09-30 against TypeScript 7.0.2, Zod 4.6.5 and Vitest 5.0.2, the current releases on that date.</div>\n\n<p>Here is a client that compiles under <code class=\"language-text\">strict</code> and is wrong in two separate ways:</p>\n\n<pre class=\"language-typescript\"><code class=\"language-typescript\">interface User {\n  id: string;\n  email: string;\n  displayName: string;\n  role: \"admin\" | \"editor\" | \"viewer\";\n  status: \"ACTIVE\" | \"SUSPENDED\";\n}\n\nasync function getUser(id: string): Promise&lt;User&gt; {\n  const res = await fetch(`https://api.example.test/users/${id}`);\n  return (await res.json()) as User; // compiles\n}\n\nasync function getUserUnasserted(id: string): Promise&lt;User&gt; {\n  const res = await fetch(`https://api.example.test/users/${id}`);\n  return res.json(); // also compiles: Promise&lt;any&gt; is assignable to Promise&lt;User&gt;\n}\n\nfunction badge(user: User): \"green\" | \"red\" {\n  switch (user.status) {\n    case \"ACTIVE\":\n      return \"green\";\n    case \"SUSPENDED\":\n      return \"red\";\n  }\n}\n\nfunction roleLabel(user: User): string {\n  return user.role.toUpperCase();\n}\n\n// What the service actually sent after its last deploy:\nconst fromWire = JSON.parse(\n  '{\"id\":\"u_1042\",\"email\":\"a@example.test\",\"displayName\":\"Asha\",\"status\":\"ARCHIVED\"}',\n) as User;\n\nconsole.log(\"badge:\", badge(fromWire));\nconsole.log(\"role:\", roleLabel(fromWire));</code></pre>\n\n<p>Run it and the first failure is silent. <code class=\"language-typescript\">badge</code> is declared to return <code class=\"language-typescript\">\"green\" | \"red\"</code> and returns <code class=\"language-typescript\">undefined</code>, because <code class=\"language-typescript\">\"ARCHIVED\"</code> matches neither case. The second failure is loud, but it is reported from <code class=\"language-typescript\">roleLabel</code>, one call away from the code that made the mistake:</p>\n\n<pre class=\"language-text\"><code class=\"language-text\">badge: undefined\nfile:///app/src/wrong.ts:20\n  return user.role.toUpperCase();\n                   ^\n\nTypeError: Cannot read properties of undefined (reading 'toUpperCase')\n    at roleLabel (file:///app/src/wrong.ts:20:20)</code></pre>\n\n<p>In a real application the payload is cached and rendered two screens later, and the stack trace points at a component that never touched the network. The bug is on the line with <code class=\"language-typescript\">as User</code>, where the only thing the type system recorded is that you promised.</p>\n\n<h2 id=\"what-the-compiler-checked-at-as-user\">What the compiler checked at <code class=\"language-typescript\">as User</code></h2>\n\n<p>Nothing about the data. The TypeScript handbook is direct about it: \"Because type assertions are removed at compile-time, there is no runtime checking associated with a type assertion. There won't be an exception or <code class=\"language-typescript\">null</code> generated if the type assertion is wrong.\" The only static rule is that an assertion must convert \"to a more specific or less specific version of a type\", which blocks <code class=\"language-typescript\">\"hello\" as number</code> and nothing else. Asserting from <code class=\"language-typescript\">any</code> always satisfies it.</p>\n\n<p>And the inputs you are asserting from are <code class=\"language-typescript\">any</code>. In the TypeScript 7.0.2 standard library, <code class=\"language-typescript\">JSON.parse</code> is declared as returning <code class=\"language-typescript\">any</code>, and the DOM lib's <code class=\"language-typescript\">Body</code> interface, which <code class=\"language-typescript\">Response</code> extends, declares <code class=\"language-typescript\">json(): Promise&lt;any&gt;</code>. MDN describes what that promise can actually resolve to: \"anything that can be represented by JSON — an object, an array, a string, a number\". The type is honest about the runtime. Your annotation is not.</p>\n\n<p>That is why <code class=\"language-typescript\">getUserUnasserted</code> is the more dangerous of the two functions above. It contains no assertion for a reviewer to question: <code class=\"language-typescript\">Promise&lt;any&gt;</code> is assignable to <code class=\"language-typescript\">Promise&lt;User&gt;</code>, so the declared return type does the asserting invisibly. A text search for <code class=\"language-text\">as User</code> will not find it. The typescript-eslint rule <code class=\"language-text\">no-unsafe-return</code> will: it disallows \"returning <code class=\"language-typescript\">Promise&lt;any&gt;</code> from an async function\" and is enabled by the <code class=\"language-text\">recommended-type-checked</code> configuration, at the cost of running lint with type information.</p>\n\n<h2 id=\"a-type-predicate-is-an-assertion-with-a-body\">A type predicate is an assertion with a body</h2>\n\n<p>The usual first fix is a hand-written guard. It reads like validation:</p>\n\n<pre class=\"language-typescript\"><code class=\"language-typescript\">function isUser(value: unknown): value is User {\n  return typeof value === \"object\" &amp;&amp; value !== null &amp;&amp; \"id\" in value;\n}\n\nconst body: unknown = await res.json();\nif (isUser(body)) {\n  body.role.toUpperCase(); // type-checks; the guard never looked at role\n}</code></pre>\n\n<p>This compiles. The handbook describes the contract of a predicate: whenever the guard is called, \"TypeScript will narrow that variable to that specific type if the original type is compatible\". The compiler checks that the predicate's parameter name is real. It does not check that the function body inspected every property the type promises. A guard that tests <code class=\"language-typescript\">id</code> and nothing else narrows to a <code class=\"language-typescript\">User</code> with a guaranteed <code class=\"language-typescript\">role</code>, and you are back where <code class=\"language-typescript\">as User</code> left you, with a function call in the way.</p>\n\n<p>For a two-field message that is tolerable. For a response with enums and dates, the guard and the interface drift apart silently the first time someone edits one of them. Derive the static type from the runtime check instead, so there is one definition.</p>\n\n<h2 id=\"unknown-in-schema-in-the-middle-domain-type-out\">Unknown in, schema in the middle, domain type out</h2>\n\n<p>The handbook says <code class=\"language-typescript\">unknown</code> \"represents any value\" but \"it's not legal to do anything with an unknown value\". That refusal is what a boundary needs: annotating the parsed body as <code class=\"language-typescript\">unknown</code> discards the <code class=\"language-typescript\">any</code> and forces a check before use.</p>\n\n<pre class=\"language-typescript\"><code class=\"language-typescript\">import * as z from \"zod\";\n\nexport const UserWire = z.object({\n  id: z.string(),\n  email: z.email(),\n  displayName: z.string(),\n  role: z.enum([\"admin\", \"editor\", \"viewer\"]),\n  status: z.enum([\"ACTIVE\", \"SUSPENDED\"]),\n  createdAt: z.iso.datetime(),\n});\nexport type UserWire = z.infer&lt;typeof UserWire&gt;;\n\n// The domain type is not the wire type: createdAt is a Date in here.\nexport interface User {\n  id: string;\n  email: string;\n  displayName: string;\n  role: UserWire[\"role\"];\n  status: UserWire[\"status\"];\n  createdAt: Date;\n}\n\nexport function parseUser(input: unknown): User {\n  const result = UserWire.safeParse(input);\n  if (!result.success) throw new UserContractError(summarize(result.error, input));\n  const { createdAt, ...rest } = result.data;\n  return { ...rest, createdAt: new Date(createdAt) };\n}\n\nexport async function fetchUser(id: string, fetchImpl: typeof fetch = fetch): Promise&lt;User&gt; {\n  const res = await fetchImpl(`https://api.example.test/users/${encodeURIComponent(id)}`);\n  if (!res.ok) throw new Error(`GET /users/${id} answered ${res.status}`);\n  const body: unknown = await res.json();\n  return parseUser(body);\n}</code></pre>\n\n<p>Four details in that file carry weight.</p>\n\n<p><code class=\"language-typescript\">safeParse</code> returns a result object instead of throwing, and Zod documents its type as a discriminated union on <code class=\"language-typescript\">success</code>, so <code class=\"language-typescript\">result.data</code> is only reachable once the check has passed. On success, Zod's basic-usage page says it \"returns a strongly-typed deep clone of the input\", which means the object your domain holds is not the object the network handed you. Later mutation of one does not reach the other.</p>\n\n<p>The domain type is separate from the wire type. <code class=\"language-typescript\">z.infer</code> gives you the shape as it crosses the wire, with <code class=\"language-typescript\">createdAt</code> as a string; the application wants a <code class=\"language-typescript\">Date</code>. Keeping a <code class=\"language-typescript\">UserWire</code> and a <code class=\"language-typescript\">User</code> makes the conversion a single, testable function instead of a <code class=\"language-typescript\">new Date(...)</code> scattered across components.</p>\n\n<p><code class=\"language-typescript\">z.iso.datetime()</code> is stricter than most people expect. Zod's API reference: \"by default, no timezone offsets are allowed\", so <code class=\"language-text\">2026-09-01T15:30:00+05:30</code> is rejected unless you write <code class=\"language-typescript\">z.iso.datetime({ offset: true })</code>. Whether an offset is legal is a contract question. Look it up in the API's published description rather than loosening the schema the first time production sends one.</p>\n\n<p>The non-OK branch is handled before parsing. An error body from a 500 is not a malformed user, and reporting it as a contract violation sends someone to read the wrong schema.</p>\n\n<h2 id=\"strip-reject-or-pass-through-undeclared-keys\">Strip, reject or pass through undeclared keys</h2>\n\n<p>Zod gives an object three answers to a key it was not told about. <code class=\"language-typescript\">z.object</code> strips: \"By default, unrecognized keys are stripped from the parsed result\". <code class=\"language-typescript\">z.strictObject</code> produces a schema \"that throws an error when unknown keys are found\". <code class=\"language-typescript\">z.looseObject</code> lets them \"pass through\".</p>\n\n<p>The right answer depends on where the parse runs. In the production client, stripping is the default worth keeping: when the provider ships an additive field, your page keeps working, and a field you never declared, such as an internal hash someone accidentally serialised, never reaches your store or your logs. A strict parse in production turns every additive deploy on the provider side into an outage on yours.</p>\n\n<p>In the test suite you want the opposite, because an undeclared key is information. Build the strict variant from the same shape so the two cannot drift:</p>\n\n<pre class=\"language-typescript\"><code class=\"language-typescript\">// Production parse: undeclared keys are dropped from result.data.\nUserWire.parse({ ...payload, passwordHash: \"…\" }); // no passwordHash in the output\n\n// Contract suite: same shape, but an undeclared key is an issue with a name.\nexport const UserWireStrict = z.strictObject(UserWire.shape);</code></pre>\n\n<p>One trap sits next to this decision. If you export the stripping schema with <code class=\"language-typescript\">z.toJSONSchema(UserWire)</code> to compare it with a published contract, the output includes <code class=\"language-text\">\"additionalProperties\": false</code>. Zod's JSON Schema page explains why: by default the conversion \"represents the output type\", and a stripped output has no extra keys. Pass <code class=\"language-typescript\">{ io: \"input\" }</code> and the line disappears. Diff the input form against the contract, or the export looks stricter than the parse that actually runs.</p>\n\n<h2 id=\"a-missing-role-and-an-unknown-status-report-the-same-code\">A missing role and an unknown status report the same code</h2>\n\n<p>This is the detail that makes naive assertions pass for the wrong reason. Parse the three broken payloads with Zod 4.6.5 and look at the raw issues:</p>\n\n<pre class=\"language-text\"><code class=\"language-text\">// role absent\n[{ \"code\": \"invalid_value\", \"values\": [\"admin\", \"editor\", \"viewer\"], \"path\": [\"role\"],\n   \"message\": \"Invalid option: expected one of \\\"admin\\\"|\\\"editor\\\"|\\\"viewer\\\"\" }]\n\n// status: \"ARCHIVED\"\n[{ \"code\": \"invalid_value\", \"values\": [\"ACTIVE\", \"SUSPENDED\"], \"path\": [\"status\"],\n   \"message\": \"Invalid option: expected one of \\\"ACTIVE\\\"|\\\"SUSPENDED\\\"\" }]\n\n// displayName absent (a plain string field, for contrast)\n[{ \"expected\": \"string\", \"code\": \"invalid_type\", \"path\": [\"displayName\"],\n   \"message\": \"Invalid input: expected string, received undefined\" }]</code></pre>\n\n<p>An absent string field gets <code class=\"language-text\">invalid_type</code> with <code class=\"language-text\">expected: \"string\"</code>. An absent enum field gets <code class=\"language-text\">invalid_value</code>, the same code as an enum field holding a value the contract does not list, and neither carries an <code class=\"language-text\">expected</code> property. A test asserting <code class=\"language-typescript\">{ path: [\"role\"], code: \"invalid_value\" }</code> cannot tell \"the v1 provider never sends role\" from \"the provider started sending role: owner\". Those are different incidents with different owners.</p>\n\n<p>The issue also does not tell you what was received. That is deliberate: Zod's error-customisation page says \"By default, Zod does not include input data in issues. This is to prevent unintentional logging of potentially sensitive input data\", and the <code class=\"language-typescript\">reportInput</code> flag turns it back on. Rather than turning it on, compute a description of what sat at the issue's path yourself, and choose what is safe to show:</p>\n\n<pre class=\"language-typescript\"><code class=\"language-typescript\">export type IssueSummary = { path: string; code: string; expected?: string; received: string };\n\n// Fields whose values are vocabulary from the contract, never user data.\nconst ECHO_SAFE = new Set([\"role\", \"status\"]);\n\nfunction receivedAt(input: unknown, path: PropertyKey[]): string {\n  let cur: unknown = input;\n  for (const key of path) {\n    if (cur === null || typeof cur !== \"object\" || !(key in cur)) return \"missing\";\n    cur = (cur as Record&lt;PropertyKey, unknown&gt;)[key];\n  }\n  if (typeof cur === \"string\" &amp;&amp; ECHO_SAFE.has(String(path.at(-1)))) return JSON.stringify(cur);\n  if (cur === null) return \"null\";\n  return Array.isArray(cur) ? \"array\" : typeof cur;\n}\n\nexport function summarize(error: z.ZodError, input: unknown): IssueSummary[] {\n  return error.issues.map((issue) =&gt; ({\n    path: issue.path.join(\".\") || \"(root)\",\n    code: issue.code,\n    ...(\"expected\" in issue ? { expected: String(issue.expected) } : {}),\n    received: receivedAt(input, issue.path),\n  }));\n}\n\nexport class UserContractError extends Error {\n  readonly issues: IssueSummary[];\n  constructor(issues: IssueSummary[]) {\n    super(`user payload broke the contract: ${JSON.stringify(issues)}`);\n    this.name = \"UserContractError\";\n    this.issues = issues;\n  }\n}</code></pre>\n\n<p>For most fields the summary records only the kind: <code class=\"language-text\">missing</code>, <code class=\"language-text\">null</code>, <code class=\"language-text\">array</code> or a <code class=\"language-typescript\">typeof</code> result. For fields whose values are vocabulary defined by the contract, the value itself is the diagnosis, so those are allowlisted by name. An email address never qualifies. Here is the error a version-skewed payload produces through <code class=\"language-typescript\">fetchUser</code>:</p>\n\n<pre class=\"language-text\"><code class=\"language-text\">UserContractError: user payload broke the contract: [{\"path\":\"role\",\"code\":\"invalid_value\",\"received\":\"missing\"},{\"path\":\"status\",\"code\":\"invalid_value\",\"received\":\"\\\"ARCHIVED\\\"\"}]</code></pre>\n\n<p>That line is the evidence a boundary failure should leave: which path, which rule, what arrived, and no personal data to scrub from the log aggregator afterwards.</p>\n\n<h2 id=\"a-vitest-suite-for-one-user-endpoint\">A Vitest suite for one user endpoint</h2>\n\n<pre class=\"language-typescript\"><code class=\"language-typescript\">import { describe, expect, test } from \"vitest\";\nimport { UserContractError, UserWire, UserWireStrict, fetchUser, parseUser, summarize } from \"./user\";\n\nconst accepted = {\n  id: \"u_1042\",\n  email: \"asha.k@example.test\",\n  displayName: \"Asha K\",\n  role: \"editor\",\n  status: \"ACTIVE\",\n  createdAt: \"2026-09-01T10:00:00Z\",\n} satisfies UserWire;\n\nconst without = &lt;K extends keyof typeof accepted&gt;(key: K) =&gt; {\n  const { [key]: _dropped, ...rest } = accepted;\n  return rest;\n};\n\ndescribe(\"user payload at the boundary\", () =&gt; {\n  test(\"accepts the published shape and converts createdAt\", () =&gt; {\n    const user = parseUser(accepted);\n    expect(user.createdAt).toBeInstanceOf(Date);\n    expect(user.role).toBe(\"editor\");\n  });\n\n  test(\"strips an undeclared key instead of passing it into the domain\", () =&gt; {\n    const user = parseUser({ ...accepted, passwordHash: \"x\" });\n    expect(user).not.toHaveProperty(\"passwordHash\");\n  });\n\n  test.each([\n    { name: \"role missing (v1 provider)\", input: without(\"role\"), path: \"role\", code: \"invalid_value\", received: \"missing\" },\n    { name: \"status added upstream\", input: { ...accepted, status: \"ARCHIVED\" }, path: \"status\", code: \"invalid_value\", received: '\"ARCHIVED\"' },\n    { name: \"displayName missing\", input: without(\"displayName\"), path: \"displayName\", code: \"invalid_type\", received: \"missing\" },\n    { name: \"createdAt with an offset\", input: { ...accepted, createdAt: \"2026-09-01T15:30:00+05:30\" }, path: \"createdAt\", code: \"invalid_format\", received: \"string\" },\n    { name: \"array instead of object\", input: [accepted], path: \"(root)\", code: \"invalid_type\", received: \"array\" },\n  ])(\"rejects $name\", ({ input, path, code, received }) =&gt; {\n    const result = UserWire.safeParse(input);\n    expect.assert(!result.success);\n    expect(summarize(result.error, input)).toEqual([expect.objectContaining({ path, code, received })]);\n  });\n\n  test(\"the strict variant reports an added key by name\", () =&gt; {\n    const result = UserWireStrict.safeParse({ ...accepted, tenantId: \"t_9\" });\n    expect.assert(!result.success);\n    expect(result.error.issues).toMatchObject([{ code: \"unrecognized_keys\", keys: [\"tenantId\"], path: [] }]);\n  });\n\n  test(\"a contract failure from fetch carries no field values\", async () =&gt; {\n    const stub = async () =&gt; Response.json({ ...accepted, role: undefined, status: \"ARCHIVED\" });\n    const failure = await fetchUser(\"u_1042\", stub).catch((e: unknown) =&gt; e);\n    expect(failure).toBeInstanceOf(UserContractError);\n    expect(String((failure as Error).message)).not.toContain(accepted.email);\n    expect((failure as UserContractError).issues.map((i) =&gt; i.path)).toEqual([\"role\", \"status\"]);\n  });\n});</code></pre>\n\n<p>The accepted fixture uses <code class=\"language-typescript\">satisfies</code>, which the TypeScript 4.9 release notes introduced to \"validate that the type of an expression matches some type, without changing the resulting type of that expression\". A typo in the fixture fails compilation, and the literal types survive. It checks your fixture against your own schema's type, which is all it can do: <code class=\"language-typescript\">satisfies</code> is as compile-time as <code class=\"language-typescript\">as</code>, and it knows nothing about what the server sends.</p>\n\n<p>The negative fixtures are derived from the accepted one with a single change each. A rejected payload that differs from the good one in three ways passes even if two of the rules are broken, because the third still trips.</p>\n\n<p>The <code class=\"language-typescript\">test.each</code> rows are objects, and the title uses <code class=\"language-text\">$name</code>, which Vitest's API reference documents as access to \"object properties and array elements with <code class=\"language-text\">$</code> prefix\". Choose that property carefully. Interpolate the payload itself and Vitest prints it into the test name:</p>\n\n<pre class=\"language-typescript\"><code class=\"language-typescript\">// Don't: the whole fixture lands in the test name, and from there in every report.\ntest.each([{ input: { email: \"asha.k@example.test\", role: \"owner\" } }])(\"rejects $input\", ...);\n// ✓ rejects { email: 'asha.k@example.test', …(1) }</code></pre>\n\n<p>Test names travel into CI logs, JUnit XML and dashboards with a far wider audience than the fixture file. Name the case; keep the data in the body.</p>\n\n<p><code class=\"language-typescript\">expect.assert(!result.success)</code> is doing type work as well as test work. Vitest re-exports Chai's <code class=\"language-typescript\">assert</code> as <code class=\"language-typescript\">expect.assert</code> and notes this is \"especially useful if you need to narrow down the type, since <code class=\"language-typescript\">expect.to*</code> methods do not support that\". After it, <code class=\"language-typescript\">result.error</code> is typed without a non-null assertion.</p>\n\n<p>None of the assertions mention a message. The regular <code class=\"language-typescript\">zod</code> package loads its English locale automatically, and messages can be replaced per schema with the <code class=\"language-typescript\">error</code> parameter, so message text is presentation that someone may change for a good reason. The path, the code and your received-kind description are the contract.</p>\n\n<p>The strict-variant test uses <code class=\"language-typescript\">toMatchObject</code>, which checks \"a subset of the properties of an object\" but, given an array, also its length. That pins exactly one issue naming <code class=\"language-text\">tenantId</code> and ignores its message.</p>\n\n<h2 id=\"letting-a-model-draft-the-schema\">Letting a model draft the schema</h2>\n\n<p>A language model will turn three example responses into a Zod schema in seconds, and for a wide response that saves real typing. What it cannot know is which observed facts are rules; the published contract and the domain owner decide which fields are required and which variants are accepted. Every key present in all the samples comes back required. Every enum comes back as the set of values that happened to appear, so a status that exists in the contract but not in the samples is rejected, and the sample set decides what production accepts. Fields that are nullable but were never null in the samples are drafted as plain strings.</p>\n\n<p>Treat the draft the way you would treat one generated from a single response in the <a href=\"/tools/json-schema\">JSON Schema generator</a>: a starting point whose <code class=\"language-text\">required</code> list and enum values you check against the published contract line by line. A practical check is to export the draft with <code class=\"language-typescript\">z.toJSONSchema(schema, { io: \"input\" })</code>, then compare its <code class=\"language-text\">required</code> array and enum lists with the OpenAPI component in the <a href=\"/tools/json-diff\">JSON Diff tool</a>. Going the other way, Zod 4 has <code class=\"language-typescript\">z.fromJSONSchema()</code>, but its own documentation calls it experimental and \"not considered part of Zod's stable API\", which is a poor property for code that gates production traffic.</p>\n\n<h2 id=\"where-a-schema-costs-more-than-it-returns\">Where a schema costs more than it returns</h2>\n\n<p>Every parse walks the whole value and, on success, clones it. On a hot path that handles thousands of messages a second, that is measurable. Zod 4.6 adds <code class=\"language-typescript\">.validate()</code>, which \"returns a boolean, never builds an error, and acts as a type guard on the schema's input type\", and which the docs put at up to 16x faster than <code class=\"language-typescript\">.safeParse().success</code> on invalid input. It fits filtering. It does not fit a boundary where you need the evidence above, because it gives you none, and it narrows to the input type, so transformations have not run.</p>\n\n<p>Do not validate values your own process constructed. A function receiving a <code class=\"language-typescript\">User</code> from another module in the same build is inside the boundary; re-parsing it adds cost and a second place to update. The boundaries worth a schema are the places data arrives from something your compiler never saw: HTTP responses, <code class=\"language-typescript\">localStorage</code> and <code class=\"language-typescript\">sessionStorage</code> (which hand you strings you will <code class=\"language-typescript\">JSON.parse</code> into <code class=\"language-typescript\">any</code>), <code class=\"language-typescript\">postMessage</code> payloads, environment variables, queue messages, and the arguments a model passes to a tool you exposed to it.</p>\n\n<p>Moving the failure earlier has its own cost: with a strict enum, a provider that adds <code class=\"language-text\">ARCHIVED</code> breaks your user page instead of mis-rendering one badge. That is usually the right trade, but decide it per field, and where a new value should degrade gracefully, model that in the schema and test it.</p>\n\n<h2 id=\"apply-boundary-validation-to-one-endpoint-today\">Apply boundary validation to one endpoint today</h2>\n\n<p>Find the places where an untyped parse is asserted straight into a domain type:</p>\n\n<pre class=\"language-bash\"><code class=\"language-bash\"># Assertions stacked directly on an untyped parse\ngrep -rnE --include='*.ts' --include='*.tsx' \\\n  '(\\.json\\(\\)\\)?|JSON\\.parse\\([^)]*\\)) as [A-Z]' src</code></pre>\n\n<p>That misses the unasserted form, where a declared return type absorbs <code class=\"language-typescript\">Promise&lt;any&gt;</code>, so also enable <code class=\"language-text\">@typescript-eslint/no-unsafe-return</code> if your lint already runs with type information.</p>\n\n<p>Pick the endpoint whose failures would hurt most. Write its schema from the published contract, not from a captured response. Change the fetch to annotate the body as <code class=\"language-typescript\">unknown</code> and return <code class=\"language-typescript\">parseUser(body)</code> or its equivalent. Then add five tests: the accepted payload, one missing required field, one enum value the contract does not list, one undeclared key against the strict variant, and one wrong top-level type.</p>\n\n<p>The evidence to keep is the summary array for each rejected case, committed as the expected value in the test. When the contract changes, the diff in that array is the review: which path, which code, and whether the new behaviour is the one the contract owner meant.</p>\n\n<h2 id=\"typescript-boundary-validation-questions\">Questions about validating at the TypeScript boundary</h2>\n\n<h3 id=\"does-a-generated-api-client-validate-responses\">Does a client generated from OpenAPI remove the need for this?</h3>\n\n<p>Only if the generator emits runtime validators, and you should confirm that by reading the generated code, not its README. Generated interfaces are the same kind of promise as a hand-written <code class=\"language-typescript\">interface User</code>: correct when the server matches the spec it was generated from, and silent when a deploy on either side skews them.</p>\n\n<h3 id=\"parse-or-safeparse-at-the-boundary\">Should the boundary call <code class=\"language-typescript\">parse</code> or <code class=\"language-typescript\">safeParse</code>?</h3>\n\n<p><code class=\"language-typescript\">parse</code> throws a <code class=\"language-typescript\">ZodError</code>; <code class=\"language-typescript\">safeParse</code> returns the result so you can build your own error first. Use <code class=\"language-typescript\">safeParse</code> where you want to control what the thrown error contains, as <code class=\"language-typescript\">parseUser</code> does, so the raw issue list and anything a future <code class=\"language-typescript\">reportInput</code> setting adds never travel further than the boundary.</p>\n\n<h3 id=\"should-the-status-enum-accept-unknown-values\">Should the status enum accept values it does not know yet?</h3>\n\n<p>Only if the contract says clients must tolerate new values. If it does, write that rule into the schema deliberately and give it a test row of its own, so the tolerance is visible in review. If it does not, rejecting <code class=\"language-text\">ARCHIVED</code> is correct, and the failing test is how you find out the provider moved first.</p>\n\n<h2 id=\"typescript-runtime-boundary-references\">Primary references</h2>\n\n<ul>\n<li><a href=\"https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#type-assertions\">TypeScript Handbook — Everyday Types: Type Assertions</a>. That type assertions \"are removed by the compiler and won't affect the runtime behavior of your code\"; that \"because type assertions are removed at compile-time, there is no runtime checking associated with a type assertion. There won't be an exception or null generated if the type assertion is wrong\"; and that assertions are only allowed to convert \"to a more specific or less specific version of a type\", which blocks \"hello\" as number but not an assertion from any.</li>\n<li><a href=\"https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates\">TypeScript Handbook — Narrowing: Using type predicates</a>. The form of a type predicate (\"parameterName is Type\", where the name must be a parameter of the current signature) and its effect: whenever the guard is called, \"TypeScript will narrow that variable to that specific type if the original type is compatible\". The article's point that the body of the guard is not checked against the type was demonstrated by compiling a guard that tests only id under strict with TypeScript 7.0.2.</li>\n<li><a href=\"https://www.typescriptlang.org/docs/handbook/2/functions.html#unknown\">TypeScript Handbook — More on Functions: unknown</a>. That \"the unknown type represents any value\" and that, unlike any, \"it's not legal to do anything with an unknown value\", the property that makes unknown the right annotation for a parsed response body.</li>\n<li><a href=\"https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-9.html#the-satisfies-operator\">TypeScript documentation: TypeScript 4.9, the satisfies operator</a>. That satisfies arrived in TypeScript 4.9 and \"lets us validate that the type of an expression matches some type, without changing the resulting type of that expression\", the basis for using it on the accepted test fixture and for the point that it is a compile-time check only.</li>\n<li><a href=\"https://github.com/microsoft/TypeScript/blob/v7.0.2/src/lib/es5.d.ts\">TypeScript v7.0.2 — src/lib/es5.d.ts</a>. That the standard library at the v7.0.2 tag declares JSON.parse(text: string, reviver?: ...): any.</li>\n<li><a href=\"https://github.com/microsoft/TypeScript/blob/v7.0.2/src/lib/dom.generated.d.ts\">TypeScript v7.0.2 — src/lib/dom.generated.d.ts</a>. That the DOM library at the v7.0.2 tag declares json(): Promise&lt;any&gt; on the Body interface and that Response extends Body, so an awaited res.json() is any and Promise&lt;any&gt; is assignable to Promise&lt;User&gt;.</li>\n<li><a href=\"https://github.com/microsoft/TypeScript/releases/tag/v7.0.2\">TypeScript 7.0.2 release</a>. That TypeScript 7.0.2 is the current stable release as of the check date (published 2026-08-20); all TypeScript snippets were compiled with it under strict.</li>\n<li><a href=\"https://developer.mozilla.org/en-US/docs/Web/API/Response/json\">MDN Web Docs: Response.json() method</a>. That json() returns a promise that \"resolves with the result of parsing the body text as JSON\", and that the resolved value \"could be anything that can be represented by JSON — an object, an array, a string, a number\", so a generic return type describes a hope, not the runtime.</li>\n<li><a href=\"https://zod.dev/basics\">Zod 4 — Basic usage</a>. That parse \"returns a strongly-typed deep clone of the input\" and throws a ZodError on failure; that safeParse returns a result whose type \"is a discriminated union\" on success; z.infer for the static type; and the Zod 4.6 .validate() method, which \"returns a boolean, never builds an error, and acts as a type guard on the schema's input type\" and is \"up to 16x faster than .safeParse().success\" on invalid input.</li>\n<li><a href=\"https://zod.dev/api#objects\">Zod 4 — Defining schemas: Objects, z.strictObject, z.looseObject, Enums, ISO datetimes</a>. That object properties are required by default and \"unrecognized keys are stripped from the parsed result\"; that z.strictObject \"throws an error when unknown keys are found\" and z.looseObject lets them \"pass through\"; z.enum for a fixed set of string values; and that z.iso.datetime() rejects offsets \"by default\" unless called with { offset: true }.</li>\n<li><a href=\"https://zod.dev/error-formatting\">Zod 4 — Formatting errors</a>. The Zod 4 issue shapes the tests assert on: invalid_type issues with expected and path, and the unrecognized_keys issue with keys and an empty path at the object root. The article's observation that a missing enum field reports invalid_value without an expected property was confirmed by running Zod 4.6.5.</li>\n<li><a href=\"https://zod.dev/error-customization#include-input-in-issues\">Zod 4 — Customizing errors: Include input in issues</a>. That \"by default, Zod does not include input data in issues. This is to prevent unintentional logging of potentially sensitive input data\", the reportInput flag that reverses it, that messages can be customised with the error parameter, and that the regular zod library loads the en locale automatically, which is why tests should not assert message text.</li>\n<li><a href=\"https://zod.dev/json-schema\">Zod 4 — JSON Schema</a>. That z.toJSONSchema() \"represents the output type\" by default and accepts io: \"input\" (confirmed by running: the output form of a stripping z.object carries additionalProperties: false, the input form does not), and that z.fromJSONSchema() is experimental and \"not considered part of Zod's stable API\".</li>\n<li><a href=\"https://github.com/colinhacks/zod/releases/tag/v4.6.5\">Zod v4.6.5 release</a>. That Zod 4.6.5 is the current release as of the check date (published 2026-09-13); every schema and issue output in the article was produced with it.</li>\n<li><a href=\"https://vitest.dev/api/test#test-each\">Vitest — Test API: test.each</a>. test.each (alias it.each) for table-driven cases, and that titles can \"access object properties and array elements with $ prefix\"; the article confirmed by running Vitest 5.0.2 that interpolating a fixture object prints its contents into the test name.</li>\n<li><a href=\"https://vitest.dev/api/expect#tomatchobject\">Vitest — Expect API: toMatchObject, toEqual, assert</a>. That toMatchObject \"asserts if an object matches a subset of the properties of an object\" and, for arrays, checks \"number and order of elements\"; and that Vitest \"reexports chai's assert API as expect.assert\", which is \"especially useful if you need to narrow down the type, since expect.to* methods do not support that\".</li>\n<li><a href=\"https://github.com/vitest-dev/vitest/releases/tag/v5.0.2\">Vitest v5.0.2 release</a>. That Vitest 5.0.2 is the current release as of the check date (published 2026-09-25); the test file in the article was run with it and all nine tests passed.</li>\n<li><a href=\"https://typescript-eslint.io/rules/no-unsafe-return/\">typescript-eslint — no-unsafe-return</a>. That the rule disallows returning any, any[] and \"Promise&lt;any&gt; from an async function\", that it is enabled by recommended-type-checked, and that it \"requires type information to run\", the tool for catching the unasserted return res.json() form a text search misses.</li>\n</ul>\n"}