React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

typescriptreactfetchapitype-guardsvalidationzod

Typing API Responses

Recipe

Type your API responses end-to-end, from fetch calls to component rendering. Use type guards and runtime validation to bridge the gap between untyped network data and your TypeScript types.

Working Example

// Define your API types
type ApiUser = {
  id: number;
  name: string;
  email: string;
  role: "admin" | "editor" | "viewer";
};
 
type ApiResponse<T> = {
  data: T;
  meta: {
    page: number;
    totalPages: number;
    totalCount: number;
  };
};
 
// Type-safe fetch wrapper
async function fetchJson<T>(url: string): Promise<T> {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}: ${response.statusText}`);
  }
  return response.json() as Promise<T>;
}
 
// Usage in a component
function UserList() {
  const [result, setResult] = useState<ApiResponse<ApiUser[]> | null>(null);
  const [error, setError] = useState<string | null>(null);
 
  useEffect(() => {
    fetchJson<ApiResponse<ApiUser[]>>("/api/users")
      .then(setResult)
      .catch((err) => setError(err.message));
  }, []);
 
  if (error) return <p>Error: {error}</p>;
  if (!result) return <p>Loading...</p>;
 
  return (
    <ul>
      {result.data.map((user) => (
        <li key={user.id}>{user.name} ({user.role})</li>
      ))}
    </ul>
  );
}

Deep Dive

How It Works

  • fetch returns Response, and response.json() returns Promise<any>. The as Promise<T> cast tells TypeScript what shape to expect, but does not validate the data at runtime.
  • A generic fetch wrapper (fetchJson<T>) centralizes error handling and typing. Every call site specifies the expected response shape.
  • For true runtime safety, pair your types with a validation library like Zod. This ensures the API actually returned the shape you expect.
  • Separate your API types (what the server sends) from your domain types (what your app uses). Map between them in a service layer.

Variations

Zod runtime validation:

import { z } from "zod";
 
const ApiUserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
  role: z.enum(["admin", "editor", "viewer"]),
});
 
type ApiUser = z.infer<typeof ApiUserSchema>;
 
const ApiResponseSchema = <T extends z.ZodType>(dataSchema: T) =>
  z.object({
    data: dataSchema,
    meta: z.object({
      page: z.number(),
      totalPages: z.number(),
      totalCount: z.number(),
    }),
  });
 
async function fetchValidated<T>(url: string, schema: z.ZodType<T>): Promise<T> {
  const response = await fetch(url);
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  const json = await response.json();
  return schema.parse(json); // Throws ZodError if validation fails
}
 
// Usage
const result = await fetchValidated(
  "/api/users",
  ApiResponseSchema(z.array(ApiUserSchema))
);

Custom type guard:

function isApiUser(value: unknown): value is ApiUser {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value &&
    "name" in value &&
    "email" in value &&
    "role" in value &&
    typeof (value as ApiUser).id === "number" &&
    typeof (value as ApiUser).name === "string"
  );
}
 
// Usage
const data: unknown = await response.json();
if (isApiUser(data)) {
  console.log(data.name); // TypeScript knows data is ApiUser
}

Error response typing:

type ApiError = {
  message: string;
  code: string;
  details?: Record<string, string[]>;
};
 
type ApiResult<T> =
  | { success: true; data: T }
  | { success: false; error: ApiError };
 
async function fetchApi<T>(url: string): Promise<ApiResult<T>> {
  try {
    const response = await fetch(url);
    const json = await response.json();
    if (!response.ok) {
      return { success: false, error: json as ApiError };
    }
    return { success: true, data: json as T };
  } catch {
    return { success: false, error: { message: "Network error", code: "NETWORK" } };
  }
}

TypeScript Notes

  • response.json() returns Promise<any>. The cast as Promise<T> is a necessary compromise since JSON parsing is inherently untyped.
  • z.infer<typeof Schema> derives the TypeScript type from a Zod schema, giving you a single source of truth.
  • Use unknown instead of any for unvalidated data. It forces you to narrow or validate before accessing properties.

Gotchas

  • Casting with as T provides zero runtime safety. If the API changes its response format, your code will fail silently until a property access crashes.
  • fetch does not throw on 4xx or 5xx responses. You must check response.ok or response.status manually.
  • API types may not match your frontend domain types (e.g., dates come as strings, not Date objects). Transform them in a mapping layer.
  • Forgetting to handle loading and error states leads to accessing .data on null, causing runtime errors.

Alternatives

ApproachProsCons
as T castSimple, zero dependenciesNo runtime validation
Zod validationRuntime + compile-time safety, single source of truthAdded dependency, parsing overhead
Type guards (is functions)No dependencies, explicit narrowingTedious for complex types, easy to get wrong
tRPCEnd-to-end type safety, no manual typingRequires server + client setup
GraphQL codegenTypes generated from schemaBuild step, GraphQL ecosystem required

FAQs

Why does response.json() return Promise<any> and what should you do about it?
  • JSON parsing is inherently untyped -- TypeScript cannot know what shape the data will be.
  • Use as Promise<T> to cast, or validate with Zod's schema.parse(json) for runtime safety.
  • The cast provides zero runtime safety -- the API could return anything.
What is the benefit of a generic fetch wrapper like fetchJson<T>?
  • Centralizes error handling (checking response.ok, catching network errors).
  • Each call site specifies the expected response shape via the type parameter.
  • Reduces duplicate fetch boilerplate across components.
How does Zod provide both compile-time and runtime type safety?
const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
});
type User = z.infer<typeof UserSchema>; // derived type
 
const data = UserSchema.parse(json); // runtime validation
  • z.infer<typeof Schema> derives the TypeScript type from the schema.
  • schema.parse(json) throws a ZodError if the data does not match at runtime.
Gotcha: Does as T casting provide any runtime protection?
  • No. as T is a compile-time-only assertion with zero runtime safety.
  • If the API changes its response format, your code will fail silently until a property access crashes.
  • For critical API boundaries, use Zod or a custom type guard instead.
Why doesn't fetch throw on 4xx or 5xx responses?
  • fetch only rejects on network failures (DNS errors, no connection).
  • HTTP error status codes (400, 404, 500) are considered successful network requests.
  • You must check response.ok or response.status manually.
When should you use a custom type guard vs Zod validation?
  • Custom type guards have no dependencies but are tedious and error-prone for complex types.
  • Zod provides a single source of truth (schema = type + validation) but adds a dependency.
  • Use Zod for API boundaries; use type guards for simple, one-off checks.
Why should API types be separate from domain types?
  • APIs may send dates as strings, use snake_case, or include fields your app does not need.
  • A mapping layer converts API types to domain types (e.g., parsing date strings to Date objects).
  • This decouples your frontend from backend schema changes.
How do you type a discriminated union for API results (success vs error)?
type ApiResult<T> =
  | { success: true; data: T }
  | { success: false; error: ApiError };
  • The success field acts as the discriminant.
  • TypeScript narrows data and error access based on the success check.
Gotcha: What happens if you forget to handle loading and error states?
  • Accessing .data on a null state causes a runtime error.
  • Always model three states: loading, error, and success.
  • Use a discriminated union or separate state variables for each.
What is the advantage of using unknown instead of any for unvalidated data?
  • unknown forces you to narrow or validate before accessing properties.
  • any silently allows all property access without checks.
  • Use const data: unknown = await response.json() as the starting point for validation.