Validate API Data in TypeScript

API responses arrive as unknown data at runtime. Learn how to validate API responses in TypeScript with type guards, Zod schemas, and a practical fetch wrapper that guarantees type safety.

7 min read

API data arrives at runtime with no type information. A fetch call to /api/users might return a User object, an error payload, a redirect HTML page, or nothing at all.

TypeScript cannot know what will come back over the network, so the parsed response returns as any. That any silently disables type checking for everything built on top of it.

Validating API data means checking the shape of every response before the rest of your application trusts it. Do this at the boundary, right after the fetch call, before the data flows into components, state management, or business logic.

The Problem with Type Assertions on API Data

A type assertion tells the compiler to trust a shape without checking it, which is exactly wrong for data you do not control. The most common shortcut looks like this.

typescripttypescript
interface User {
  id: number;
  name: string;
}
 
async function getUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  return response.json() as Promise<User>;
}

The as Promise&lt;User&gt; assertion tells the compiler the response will be a User. If the API changes and removes the name field, the compiler says nothing. The error surfaces later as a confusing runtime failure, far from the fetch call where the problem started.

The fix is to validate, not assert.

A Validation Pipeline for API Responses

A safe API call handles three layers of potential failure: the network request itself, the HTTP status code, and the shape of the response body. Structure your fetch logic to address each layer explicitly, starting with the shape you expect back.

typescripttypescript
interface User {
  id: number;
  name: string;
  email: string;
}

A type guard checks every property this shape requires before the compiler will treat a value as a User, so a response missing any of the three fields gets rejected instead of passing silently.

typescripttypescript
function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value &&
    typeof (value as Record<string, unknown>).id === "number" &&
    "name" in value &&
    typeof (value as Record<string, unknown>).name === "string" &&
    "email" in value &&
    typeof (value as Record<string, unknown>).email === "string"
  );
}

If any check fails, the guard returns false. Now use it in a fetch wrapper that checks the HTTP status first.

typescripttypescript
async function fetchValidatedUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}: Failed to fetch user`);
  }
  const json: unknown = await response.json();
  return validateUser(json);
}

A separate helper handles the shape check, keeping each function focused on one failure layer instead of mixing the HTTP concern with the shape concern.

typescripttypescript
function validateUser(json: unknown): User {
  if (!isUser(json)) {
    throw new Error("Response did not match expected User shape");
  }
  return json;
}

Together these two functions handle all three failure layers. A network error throws during the fetch call. A non-2xx status throws with the status code, and a response that parses but has the wrong shape throws with a clear message from the guard.

Building a Reusable Typed Fetcher

Writing a separate fetch wrapper for every endpoint is repetitive, and each copy is a chance to forget one of the three failure checks. A generic fetcher accepts the URL and a validator, then returns the validated type so every endpoint shares the same safety guarantees.

typescripttypescript
async function fetchAndValidate<T>(
  url: string,
  validate: (value: unknown) => value is T
): Promise<T> {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`HTTP ${response.status} from ${url}`);
  }
  const json: unknown = await response.json();
  if (!validate(json)) {
    throw new Error(`Response from ${url} did not match expected shape`);
  }
  return json;
}

Now every API call is a one-liner, since the URL and the validator are the only things that change between endpoints.

typescripttypescript
const user = await fetchAndValidate("/api/users/42", isUser);
const posts = await fetchAndValidate("/api/posts", isPostArray);

The generic type parameter is inferred from the validator's return type. You get full type safety on the result without writing any per-endpoint boilerplate.

API data validation pipeline

The diagram shows the three gates every API response must pass through: HTTP status, JSON parsing, and shape validation. Data only reaches your application code through the final typed path, and a failure at any gate stops execution before untyped data can spread further.

Validating with Zod

Hand-written type guards become tedious for large response shapes. Zod replaces both the TypeScript interface and the validation function with a single schema definition.

typescripttypescript
import { z } from "zod";
 
const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.email(),
});
 
type User = z.infer<typeof UserSchema>;

The User type is inferred from the schema. The schema also serves as the runtime validator. Use it with the same generic fetcher, adapted for Zod.

typescripttypescript
async function fetchWithSchema<T>(
  url: string,
  schema: z.ZodSchema<T>
): Promise<T> {
  const response = await fetch(url);
 
  if (!response.ok) {
    throw new Error(`HTTP ${response.status} from ${url}`);
  }
 
  const json: unknown = await response.json();
  return schema.parse(json);
}

Calling parse on the schema throws a detailed error describing which field failed and why. This is far more useful than a generic "shape mismatch" message from a hand-written guard.

For a deeper look at Zod, see use Zod with TypeScript. For the underlying validation concepts, see runtime validation in TypeScript.

Distinguishing HTTP Errors from Validation Errors

A failed API call can mean different things depending on the status code. A 404 means the resource does not exist. A 422 means the server accepted the request but the data was invalid, and a 500 means the server itself crashed.

Your error handling should distinguish these cases rather than lumping them together as a generic failure.

typescripttypescript
function checkResponseStatus(response: Response, id: string): void {
  if (response.status === 404) {
    throw new Error(`User ${id} not found`);
  }
  if (response.status >= 500) {
    throw new Error("Server error, try again later");
  }
  if (!response.ok) {
    throw new Error(`Unexpected HTTP ${response.status}`);
  }
}

The fetch function calls this check before it ever looks at the response body, so a 404 or 500 never reaches the schema parser at all.

typescripttypescript
async function fetchUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  checkResponseStatus(response, id);
  return UserSchema.parse(await response.json());
}

Each status code range gets its own error message. The caller can handle not-found differently from server errors, which might warrant a retry. Schema validation happens only after the HTTP layer passes.

Handling Validation Errors Gracefully

Not every validation failure should throw. For user-facing features where a malformed response should show a fallback rather than crash the whole screen, return a Result type instead so the caller can decide how to present the failure.

typescripttypescript
type FetchResult<T> = { ok: true; value: T } | { ok: false; error: string };

The function wraps every failure path, including the network request itself, inside this single return type, reusing the throwing fetcher from earlier instead of duplicating its logic.

typescripttypescript
async function safeFetchAndValidate<T>(
  url: string,
  validate: (value: unknown) => value is T
): Promise<FetchResult<T>> {
  try {
    const value = await fetchAndValidate(url, validate);
    return { ok: true, value };
  } catch (err) {
    const message = err instanceof Error ? err.message : "Request failed";
    return { ok: false, error: message };
  }
}

The caller checks ok to decide what to do. This pattern works well for UI code where showing an error banner is better than crashing. For more on this pattern, see Result pattern in TypeScript.

Rune AI

Rune AI

Key Insights

  • API responses are unknown at runtime and must be validated, never asserted.
  • Validate at the boundary: right after fetch, before data enters application code.
  • A typed fetch wrapper combines HTTP errors and validation into one safe pipeline.
  • Zod schemas produce both the TypeScript type and the runtime validator.
  • Handle HTTP errors and validation errors separately for clear debugging.
RunePowered by Rune AI

Frequently Asked Questions

Why can't I just type-assert API responses?

Because the API can return data in any shape at runtime: a different version, an error object, or even HTML if the server is misconfigured. A type assertion tells the compiler to trust you, but provides zero runtime protection. Your code will fail unpredictably later.

Should I validate every API response field?

Validate every field your application depends on. If you only use id and name from a User object, you can skip validating unused fields. But the fields you do use must be validated, or a missing field will cause a runtime error far from the fetch call.

How do I handle API version changes safely?

Validate at the boundary. If a new API version changes response shapes, your validators will catch the mismatch immediately. This makes version upgrades safer because you see exactly which endpoints broke instead of hunting down scattered runtime errors.

Conclusion

API data is the most common source of untyped values in a TypeScript application. Validating at the boundary with type guards or Zod schemas turns unknown network data into trusted typed values before it reaches your business logic. A typed fetch wrapper makes this pattern automatic and prevents unvalidated data from ever entering your codebase.