Type Safety Stops at Runtime: Test the TypeScript Boundary
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.

as User is deleted by the compiler and checks nothing at runtime, and both JSON.parse and Response.json() are typed as any, so a network payload can reach your domain code in any shape. Annotate the parsed body as unknown, 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.import * as z from "zod", z.strictObject, z.iso.datetime(), z.treeifyError). 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 invalid_value. 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.Here is a client that compiles under strict and is wrong in two separate ways:
interface User {
id: string;
email: string;
displayName: string;
role: "admin" | "editor" | "viewer";
status: "ACTIVE" | "SUSPENDED";
}
async function getUser(id: string): Promise<User> {
const res = await fetch(`https://api.example.test/users/${id}`);
return (await res.json()) as User; // compiles
}
async function getUserUnasserted(id: string): Promise<User> {
const res = await fetch(`https://api.example.test/users/${id}`);
return res.json(); // also compiles: Promise<any> is assignable to Promise<User>
}
function badge(user: User): "green" | "red" {
switch (user.status) {
case "ACTIVE":
return "green";
case "SUSPENDED":
return "red";
}
}
function roleLabel(user: User): string {
return user.role.toUpperCase();
}
// What the service actually sent after its last deploy:
const fromWire = JSON.parse(
'{"id":"u_1042","email":"[email protected]","displayName":"Asha","status":"ARCHIVED"}',
) as User;
console.log("badge:", badge(fromWire));
console.log("role:", roleLabel(fromWire));
Run it and the first failure is silent. badge is declared to return "green" | "red" and returns undefined, because "ARCHIVED" matches neither case. The second failure is loud, but it is reported from roleLabel, one call away from the code that made the mistake:
badge: undefined
file:///app/src/wrong.ts:20
return user.role.toUpperCase();
^
TypeError: Cannot read properties of undefined (reading 'toUpperCase')
at roleLabel (file:///app/src/wrong.ts:20:20)
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 as User, where the only thing the type system recorded is that you promised.
What the compiler checked at as User
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 null 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 "hello" as number and nothing else. Asserting from any always satisfies it.
And the inputs you are asserting from are any. In the TypeScript 7.0.2 standard library, JSON.parse is declared as returning any, and the DOM lib's Body interface, which Response extends, declares json(): Promise<any>. 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.
That is why getUserUnasserted is the more dangerous of the two functions above. It contains no assertion for a reviewer to question: Promise<any> is assignable to Promise<User>, so the declared return type does the asserting invisibly. A text search for as User will not find it. The typescript-eslint rule no-unsafe-return will: it disallows "returning Promise<any> from an async function" and is enabled by the recommended-type-checked configuration, at the cost of running lint with type information.
A type predicate is an assertion with a body
The usual first fix is a hand-written guard. It reads like validation:
function isUser(value: unknown): value is User {
return typeof value === "object" && value !== null && "id" in value;
}
const body: unknown = await res.json();
if (isUser(body)) {
body.role.toUpperCase(); // type-checks; the guard never looked at role
}
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 id and nothing else narrows to a User with a guaranteed role, and you are back where as User left you, with a function call in the way.
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.
Unknown in, schema in the middle, domain type out
The handbook says unknown "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 unknown discards the any and forces a check before use.
import * as z from "zod";
export const UserWire = z.object({
id: z.string(),
email: z.email(),
displayName: z.string(),
role: z.enum(["admin", "editor", "viewer"]),
status: z.enum(["ACTIVE", "SUSPENDED"]),
createdAt: z.iso.datetime(),
});
export type UserWire = z.infer<typeof UserWire>;
// The domain type is not the wire type: createdAt is a Date in here.
export interface User {
id: string;
email: string;
displayName: string;
role: UserWire["role"];
status: UserWire["status"];
createdAt: Date;
}
export function parseUser(input: unknown): User {
const result = UserWire.safeParse(input);
if (!result.success) throw new UserContractError(summarize(result.error, input));
const { createdAt, ...rest } = result.data;
return { ...rest, createdAt: new Date(createdAt) };
}
export async function fetchUser(id: string, fetchImpl: typeof fetch = fetch): Promise<User> {
const res = await fetchImpl(`https://api.example.test/users/${encodeURIComponent(id)}`);
if (!res.ok) throw new Error(`GET /users/${id} answered ${res.status}`);
const body: unknown = await res.json();
return parseUser(body);
}
Four details in that file carry weight.
safeParse returns a result object instead of throwing, and Zod documents its type as a discriminated union on success, so result.data 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.
The domain type is separate from the wire type. z.infer gives you the shape as it crosses the wire, with createdAt as a string; the application wants a Date. Keeping a UserWire and a User makes the conversion a single, testable function instead of a new Date(...) scattered across components.
z.iso.datetime() is stricter than most people expect. Zod's API reference: "by default, no timezone offsets are allowed", so 2026-09-01T15:30:00+05:30 is rejected unless you write z.iso.datetime({ offset: true }). 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.
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.
Strip, reject or pass through undeclared keys
Zod gives an object three answers to a key it was not told about. z.object strips: "By default, unrecognized keys are stripped from the parsed result". z.strictObject produces a schema "that throws an error when unknown keys are found". z.looseObject lets them "pass through".
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.
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:
// Production parse: undeclared keys are dropped from result.data.
UserWire.parse({ ...payload, passwordHash: "…" }); // no passwordHash in the output
// Contract suite: same shape, but an undeclared key is an issue with a name.
export const UserWireStrict = z.strictObject(UserWire.shape);
One trap sits next to this decision. If you export the stripping schema with z.toJSONSchema(UserWire) to compare it with a published contract, the output includes "additionalProperties": false. Zod's JSON Schema page explains why: by default the conversion "represents the output type", and a stripped output has no extra keys. Pass { io: "input" } and the line disappears. Diff the input form against the contract, or the export looks stricter than the parse that actually runs.
A missing role and an unknown status report the same code
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:
// role absent
[{ "code": "invalid_value", "values": ["admin", "editor", "viewer"], "path": ["role"],
"message": "Invalid option: expected one of \"admin\"|\"editor\"|\"viewer\"" }]
// status: "ARCHIVED"
[{ "code": "invalid_value", "values": ["ACTIVE", "SUSPENDED"], "path": ["status"],
"message": "Invalid option: expected one of \"ACTIVE\"|\"SUSPENDED\"" }]
// displayName absent (a plain string field, for contrast)
[{ "expected": "string", "code": "invalid_type", "path": ["displayName"],
"message": "Invalid input: expected string, received undefined" }]
An absent string field gets invalid_type with expected: "string". An absent enum field gets invalid_value, the same code as an enum field holding a value the contract does not list, and neither carries an expected property. A test asserting { path: ["role"], code: "invalid_value" } cannot tell "the v1 provider never sends role" from "the provider started sending role: owner". Those are different incidents with different owners.
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 reportInput 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:
export type IssueSummary = { path: string; code: string; expected?: string; received: string };
// Fields whose values are vocabulary from the contract, never user data.
const ECHO_SAFE = new Set(["role", "status"]);
function receivedAt(input: unknown, path: PropertyKey[]): string {
let cur: unknown = input;
for (const key of path) {
if (cur === null || typeof cur !== "object" || !(key in cur)) return "missing";
cur = (cur as Record<PropertyKey, unknown>)[key];
}
if (typeof cur === "string" && ECHO_SAFE.has(String(path.at(-1)))) return JSON.stringify(cur);
if (cur === null) return "null";
return Array.isArray(cur) ? "array" : typeof cur;
}
export function summarize(error: z.ZodError, input: unknown): IssueSummary[] {
return error.issues.map((issue) => ({
path: issue.path.join(".") || "(root)",
code: issue.code,
...("expected" in issue ? { expected: String(issue.expected) } : {}),
received: receivedAt(input, issue.path),
}));
}
export class UserContractError extends Error {
readonly issues: IssueSummary[];
constructor(issues: IssueSummary[]) {
super(`user payload broke the contract: ${JSON.stringify(issues)}`);
this.name = "UserContractError";
this.issues = issues;
}
}
For most fields the summary records only the kind: missing, null, array or a typeof 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 fetchUser:
UserContractError: user payload broke the contract: [{"path":"role","code":"invalid_value","received":"missing"},{"path":"status","code":"invalid_value","received":"\"ARCHIVED\""}]
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.
A Vitest suite for one user endpoint
import { describe, expect, test } from "vitest";
import { UserContractError, UserWire, UserWireStrict, fetchUser, parseUser, summarize } from "./user";
const accepted = {
id: "u_1042",
email: "[email protected]",
displayName: "Asha K",
role: "editor",
status: "ACTIVE",
createdAt: "2026-09-01T10:00:00Z",
} satisfies UserWire;
const without = <K extends keyof typeof accepted>(key: K) => {
const { [key]: _dropped, ...rest } = accepted;
return rest;
};
describe("user payload at the boundary", () => {
test("accepts the published shape and converts createdAt", () => {
const user = parseUser(accepted);
expect(user.createdAt).toBeInstanceOf(Date);
expect(user.role).toBe("editor");
});
test("strips an undeclared key instead of passing it into the domain", () => {
const user = parseUser({ ...accepted, passwordHash: "x" });
expect(user).not.toHaveProperty("passwordHash");
});
test.each([
{ name: "role missing (v1 provider)", input: without("role"), path: "role", code: "invalid_value", received: "missing" },
{ name: "status added upstream", input: { ...accepted, status: "ARCHIVED" }, path: "status", code: "invalid_value", received: '"ARCHIVED"' },
{ name: "displayName missing", input: without("displayName"), path: "displayName", code: "invalid_type", received: "missing" },
{ name: "createdAt with an offset", input: { ...accepted, createdAt: "2026-09-01T15:30:00+05:30" }, path: "createdAt", code: "invalid_format", received: "string" },
{ name: "array instead of object", input: [accepted], path: "(root)", code: "invalid_type", received: "array" },
])("rejects $name", ({ input, path, code, received }) => {
const result = UserWire.safeParse(input);
expect.assert(!result.success);
expect(summarize(result.error, input)).toEqual([expect.objectContaining({ path, code, received })]);
});
test("the strict variant reports an added key by name", () => {
const result = UserWireStrict.safeParse({ ...accepted, tenantId: "t_9" });
expect.assert(!result.success);
expect(result.error.issues).toMatchObject([{ code: "unrecognized_keys", keys: ["tenantId"], path: [] }]);
});
test("a contract failure from fetch carries no field values", async () => {
const stub = async () => Response.json({ ...accepted, role: undefined, status: "ARCHIVED" });
const failure = await fetchUser("u_1042", stub).catch((e: unknown) => e);
expect(failure).toBeInstanceOf(UserContractError);
expect(String((failure as Error).message)).not.toContain(accepted.email);
expect((failure as UserContractError).issues.map((i) => i.path)).toEqual(["role", "status"]);
});
});
The accepted fixture uses satisfies, 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: satisfies is as compile-time as as, and it knows nothing about what the server sends.
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.
The test.each rows are objects, and the title uses $name, which Vitest's API reference documents as access to "object properties and array elements with $ prefix". Choose that property carefully. Interpolate the payload itself and Vitest prints it into the test name:
// Don't: the whole fixture lands in the test name, and from there in every report.
test.each([{ input: { email: "[email protected]", role: "owner" } }])("rejects $input", ...);
// ✓ rejects { email: '[email protected]', …(1) }
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.
expect.assert(!result.success) is doing type work as well as test work. Vitest re-exports Chai's assert as expect.assert and notes this is "especially useful if you need to narrow down the type, since expect.to* methods do not support that". After it, result.error is typed without a non-null assertion.
None of the assertions mention a message. The regular zod package loads its English locale automatically, and messages can be replaced per schema with the error 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.
The strict-variant test uses toMatchObject, which checks "a subset of the properties of an object" but, given an array, also its length. That pins exactly one issue naming tenantId and ignores its message.
Letting a model draft the schema
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.
Treat the draft the way you would treat one generated from a single response in the JSON Schema generator: a starting point whose required list and enum values you check against the published contract line by line. A practical check is to export the draft with z.toJSONSchema(schema, { io: "input" }), then compare its required array and enum lists with the OpenAPI component in the JSON Diff tool. Going the other way, Zod 4 has z.fromJSONSchema(), 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.
Where a schema costs more than it returns
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 .validate(), 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 .safeParse().success 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.
Do not validate values your own process constructed. A function receiving a User 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, localStorage and sessionStorage (which hand you strings you will JSON.parse into any), postMessage payloads, environment variables, queue messages, and the arguments a model passes to a tool you exposed to it.
Moving the failure earlier has its own cost: with a strict enum, a provider that adds ARCHIVED 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.
Apply boundary validation to one endpoint today
Find the places where an untyped parse is asserted straight into a domain type:
# Assertions stacked directly on an untyped parse
grep -rnE --include='*.ts' --include='*.tsx' \
'(\.json\(\)\)?|JSON\.parse\([^)]*\)) as [A-Z]' src
That misses the unasserted form, where a declared return type absorbs Promise<any>, so also enable @typescript-eslint/no-unsafe-return if your lint already runs with type information.
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 unknown and return parseUser(body) 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.
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.
Questions about validating at the TypeScript boundary
Does a client generated from OpenAPI remove the need for this?
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 interface User: correct when the server matches the spec it was generated from, and silent when a deploy on either side skews them.
Should the boundary call parse or safeParse?
parse throws a ZodError; safeParse returns the result so you can build your own error first. Use safeParse where you want to control what the thrown error contains, as parseUser does, so the raw issue list and anything a future reportInput setting adds never travel further than the boundary.
Should the status enum accept values it does not know yet?
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 ARCHIVED is correct, and the failing test is how you find out the provider moved first.
Primary references
- TypeScript Handbook — Everyday Types: Type Assertions. 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.
- TypeScript Handbook — Narrowing: Using type predicates. 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.
- TypeScript Handbook — More on Functions: unknown. 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.
- TypeScript documentation: TypeScript 4.9, the satisfies operator. 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.
- TypeScript v7.0.2 — src/lib/es5.d.ts. That the standard library at the v7.0.2 tag declares JSON.parse(text: string, reviver?: ...): any.
- TypeScript v7.0.2 — src/lib/dom.generated.d.ts. That the DOM library at the v7.0.2 tag declares json(): Promise<any> on the Body interface and that Response extends Body, so an awaited res.json() is any and Promise<any> is assignable to Promise<User>.
- TypeScript 7.0.2 release. 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.
- MDN Web Docs: Response.json() method. 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.
- Zod 4 — Basic usage. 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.
- Zod 4 — Defining schemas: Objects, z.strictObject, z.looseObject, Enums, ISO datetimes. 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 }.
- Zod 4 — Formatting errors. 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.
- Zod 4 — Customizing errors: Include input in issues. 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.
- Zod 4 — JSON Schema. 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".
- Zod v4.6.5 release. 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.
- Vitest — Test API: test.each. 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.
- Vitest — Expect API: toMatchObject, toEqual, assert. 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".
- Vitest v5.0.2 release. 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.
- typescript-eslint — no-unsafe-return. That the rule disallows returning any, any[] and "Promise<any> 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.
TypeScript Quality Engineering
TypeScript Quality Engineering
Get part 2: Choose Vitest, Browser Mode, or Playwright by Risk
It is live now: read part 2. The form below subscribes you to The Automation Brief, the site newsletter.
No spam, no sending cadence promised. Unsubscribe any time.