TypeScript Runtime Validation: Close the Gap at API Boundaries
TypeScript protects your code at compile time, but API data is untrusted at runtime. Learn how to validate boundaries without spreading defensive checks.
A frontend can pass every TypeScript check and still crash when the backend returns a missing field, a renamed enum value, or an error payload where the UI expected a user object. The type says what your application believes it will receive. It does not prove what arrived over the network.
This gap is easy to ignore in a small application. In a complex product, it becomes a recurring source of blank screens, misleading UI states, and bugs that only appear with one account, one feature flag, or one older version of the API. The practical solution is to treat external data as unknown at the boundary, validate it once, and keep the rest of the application strongly typed.
Compile-time types do not validate network data
Consider a typed API client that promises to return a User. The generic type may make the call site pleasant to use, but it does not inspect the response body. A server can return malformed JSON, a partial object, or a successful response with a different contract, and the compiler will never know.
type User = {
id: string;
name: string;
role: "admin" | "member";
};
async function getUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
return response.json() as Promise<User>;
}The assertion above changes the compiler's view, not the runtime value. If role is null or name is absent, the application still receives invalid data. The failure will happen later, often far from the request that introduced it.
Make the boundary explicit
The boundary should have three responsibilities: receive unknown data, validate the shape and business-critical constraints, and return either a trusted value or a controlled error. A schema library such as Zod, Valibot, or a small hand-written parser can implement the validation. The important architectural decision is not the library; it is the location.
import { z } from "zod";
const userSchema = z.object({
id: z.string(),
name: z.string().min(1),
role: z.enum(["admin", "member"]),
});
type User = z.infer<typeof userSchema>;
async function getUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
const payload: unknown = await response.json();
return userSchema.parse(payload);
}Now the inferred User type and the runtime schema come from the same definition. If the payload is invalid, parse throws a known validation error at the API boundary rather than allowing corrupted data to travel through components, state stores, and analytics code.
Validate more than property names
A useful schema describes the assumptions the product actually depends on. Required fields and primitive types are only the starting point. Also consider:
- Enum values that may change as the backend evolves.
- Nullable fields that are different from optional fields.
- Dates, URLs, currency values, and identifiers with format requirements.
- Nested objects that may be omitted for permission or performance reasons.
- Discriminated response variants such as success, validation error, and authorization failure.
Do not automatically reject every unfamiliar field. Forward-compatible clients often need to tolerate additive changes from the server. Strictness should be applied where an invalid value would produce a wrong decision, unsafe action, or broken rendering.
Do not turn validation errors into blank screens
Validation is only useful if the UI has a deliberate failure path. A request hook should distinguish transport failures, authorization failures, and contract failures. These cases have different actions: retry a timeout, redirect after an expired session, and report or isolate an incompatible payload.
type ApiResult<T> =
| { status: "ok"; data: T }
| { status: "unauthorized" }
| { status: "invalid"; error: Error };
async function loadUser(id: string): Promise<ApiResult<User>> {
try {
const data = await getUser(id);
return { status: "ok", data };
} catch (error) {
if (error instanceof z.ZodError) {
return { status: "invalid", error };
}
return { status: "unauthorized" };
}
}In React, the component can then render explicit states instead of guessing from a collection of booleans. A contract failure might show a recoverable message to the user while recording the endpoint, schema version, and safe diagnostic details for engineering.
Where teams usually get this wrong
- Validating inside every component: this duplicates logic and creates inconsistent assumptions.
- Using type assertions as validation: assertions remove compiler warnings but inspect no runtime data.
- Parsing only the happy path: error payloads and empty collections deserve schemas too.
- Logging complete payloads: responses may contain personal or sensitive information.
- Adding schemas without ownership: a schema that is never updated with the API contract becomes another source of confusion.
A practical pattern is to keep schemas beside API functions, expose parsed domain values to the rest of the application, and test representative payloads at the boundary. Contract tests can verify that the frontend and backend agree, while fixture tests protect against regressions in edge cases.
A proportionate adoption plan
- Start with the endpoints that feed critical workflows or have changed frequently.
- Replace response assertions with unknown plus runtime parsing.
- Define a shared error model and map validation failures to observable, safe diagnostics.
- Add fixtures for valid, partial, outdated, and unauthorized responses.
- Measure the result through fewer rendering errors and faster diagnosis, not by counting schemas.
Runtime validation is not a reason to duplicate every backend type manually or block delivery until an entire product is converted. It is a boundary discipline: untrusted data stays untrusted until checked, and the rest of the codebase can work with assumptions that are actually true.
For larger products, this boundary often becomes part of a broader frontend architecture and modernization effort. If your application has fragile API contracts or too much defensive logic spread across components, WebMriya can help through complex web application development or legacy modernization.
Frequently asked questions
Do I need runtime validation if the backend and frontend are written in TypeScript?
Yes. TypeScript checks your source code at compile time, but it cannot verify data received over HTTP. Runtime validation protects the boundary where external data enters the application.
Should every API response have a schema?
Not necessarily on the first day. Prioritize responses that drive critical workflows, contain user-controlled data, or change frequently, then expand coverage as the value becomes clear.
Can runtime validation replace API contract testing?
No. Validation protects the client when a response is wrong, while contract testing helps detect disagreement between services before deployment. They solve related but different problems.