How to Type API Responses in TypeScript for REST, GraphQL, and Fetch Clients
apigraphqlrestdata modelingfetchtypescript

How to Type API Responses in TypeScript for REST, GraphQL, and Fetch Clients

TTypeScript Toolbox Editorial
2026-06-11
10 min read

A practical guide to typing REST, GraphQL, and fetch responses in TypeScript with safer patterns for validation, mapping, and reuse.

Typing API responses in TypeScript is less about creating perfect interfaces and more about building a reliable boundary between your application and external data. This guide gives you a reusable structure for REST, GraphQL, and fetch-based clients so your team can model responses clearly, validate unknown data where it matters, and evolve API types without turning every request into a nest of assertions.

Overview

If you work with external APIs long enough, you run into the same tension: TypeScript makes your codebase safer, but API responses arrive at runtime as untrusted data. The core challenge is not just how to write types, but where those types should live, how strict they should be, and how to connect them to the network layer without misleading the compiler or future maintainers.

A useful approach to TypeScript API typing starts with a simple rule: separate the shape you hope to receive from the data you have actually verified. In practice, that usually means keeping a few layers distinct:

  • Transport types that represent raw REST or GraphQL payloads.
  • Runtime validation or parsing for data you do not fully control.
  • Domain types that your application uses after parsing, normalization, or transformation.
  • Client helpers that standardize success, error, pagination, and loading patterns.

This structure is durable because APIs change. A team may start with handwritten interfaces, later adopt schema generation, and eventually introduce runtime validators or shared contracts. If the code is organized around boundaries rather than tools, those changes are manageable.

For readers learning TypeScript through real-world cases, this is one of the most valuable advanced patterns: avoid treating response.json() as trusted data. TypeScript can describe the intended shape, but only your application logic can decide whether the shape is valid enough to use.

Before going deeper, it helps to define a principle that applies across REST, GraphQL, and custom fetch clients:

Type external data at the edge, narrow it as early as practical, and expose stable application-facing types to the rest of the codebase.

That single principle prevents many common problems: duplicated interfaces, unsafe casts, brittle UI assumptions, and error handling that collapses into any.

Template structure

Here is a reusable template you can apply to most API layers. The exact tools may vary, but the structure stays useful over time.

1. Define base response conventions

Most teams benefit from a small set of shared primitives instead of ad hoc response typing in every file.

type ApiSuccess<T> = {
  ok: true;
  status: number;
  data: T;
};

type ApiFailure<E = unknown> = {
  ok: false;
  status: number;
  error: E;
};

type ApiResult<T, E = unknown> = ApiSuccess<T> | ApiFailure<E>;

This pattern is useful because it forces callers to handle both branches. It also makes fetch wrappers easier to compose. Instead of throwing for every non-2xx status and making downstream code guess at shapes, you can return a typed discriminated union.

2. Model transport types separately from domain types

Do not assume the wire format is the same as the shape your UI or business logic should use.

type UserDto = {
  id: string;
  full_name: string;
  created_at: string;
  is_active: boolean;
};

type User = {
  id: string;
  fullName: string;
  createdAt: Date;
  isActive: boolean;
};

function mapUser(dto: UserDto): User {
  return {
    id: dto.id,
    fullName: dto.full_name,
    createdAt: new Date(dto.created_at),
    isActive: dto.is_active,
  };
}

This extra layer can feel verbose at first, but it pays off quickly. If the API uses snake_case, nullable timestamps, or nested metadata you do not want throughout the app, mapping gives you a stable internal shape.

3. Treat JSON as unknown at the boundary

The return value of response.json() should be considered unknown until narrowed. Even if you do not use a dedicated validation library, your fetch client should reflect that uncertainty.

async function getJson(response: Response): Promise<unknown> {
  return response.json();
}

That may look stricter than necessary, but it prevents a common anti-pattern:

const data = (await response.json()) as UserDto;

A cast like that can silence the compiler while preserving every runtime risk.

4. Parse and narrow data

You can narrow data with custom type guards, schema validators, or generated types plus targeted checks. The important part is that narrowing is explicit.

function isUserDto(value: unknown): value is UserDto {
  if (typeof value !== "object" || value === null) return false;

  const v = value as Record<string, unknown>;
  return (
    typeof v.id === "string" &&
    typeof v.full_name === "string" &&
    typeof v.created_at === "string" &&
    typeof v.is_active === "boolean"
  );
}

If you want a deeper comparison of validator choices, see Zod vs Yup vs Valibot: Runtime Validation Libraries for TypeScript Compared.

5. Centralize fetch behavior

Use one client wrapper for headers, parsing, error formatting, and result typing.

async function fetchUser(id: string): Promise<ApiResult<User, string>> {
  const response = await fetch(`/api/users/${id}`);
  const json = await getJson(response);

  if (!response.ok) {
    return {
      ok: false,
      status: response.status,
      error: "Request failed",
    };
  }

  if (!isUserDto(json)) {
    return {
      ok: false,
      status: response.status,
      error: "Invalid user payload",
    };
  }

  return {
    ok: true,
    status: response.status,
    data: mapUser(json),
  };
}

Now every caller sees a predictable shape. In React, Next.js, or Node services, that consistency matters more than clever type gymnastics. If you are building app-level architecture around these boundaries, related patterns are covered in React with TypeScript: Best Practices for Props, Hooks, and Component APIs and Next.js with TypeScript: App Router Patterns, Server Actions, and Type Safety.

6. Use utility types sparingly

Utility types like Pick, Omit, Partial, and indexed access types can reduce repetition, but they should clarify intent, not obscure it. For example:

type UserSummary = Pick<User, "id" | "fullName" | "isActive">;

That is readable. By contrast, deeply composed mapped types for every endpoint often become harder to maintain than plain named types.

How to customize

The right response model depends on your API style, ownership level, and tolerance for runtime checks. Here is how to adapt the template.

For REST APIs

REST responses often vary by endpoint and status code. Model those differences directly instead of forcing one generic envelope everywhere.

type ListUsersResponse = {
  users: UserDto[];
  nextCursor: string | null;
};

type NotFoundError = {
  code: "NOT_FOUND";
  message: string;
};

If your backend uses consistent envelopes, represent them once:

type RestEnvelope<T> = {
  data: T;
  meta?: {
    requestId?: string;
  };
};

But do not force every endpoint into a fake standard if the actual API is inconsistent. The type layer should describe reality, not hide it.

For teams migrating from JavaScript, this is an especially important best practice. Start with obvious endpoint shapes and add shared abstractions only after patterns are stable. Over-abstracting too early often makes a TypeScript tutorial style example look neat while making production code harder to change.

For GraphQL APIs

GraphQL responses usually have a predictable outer shape but highly specific inner data based on the query.

type GraphQLError = {
  message: string;
  path?: Array<string | number>;
};

type GraphQLResponse<T> = {
  data?: T;
  errors?: GraphQLError[];
};

Then define the query-specific payload:

type GetUserQuery = {
  user: {
    id: string;
    fullName: string;
    email: string | null;
  } | null;
};

One useful habit with GraphQL TypeScript types is to treat each operation as its own contract. Avoid broad global entity types unless they reflect a real shared abstraction. In GraphQL, field selection matters, so a User in one query may not match a User in another.

If your team uses code generation from a schema or operations, keep the same architectural rule: generated transport types are still external-facing types. Your app may still benefit from mapping them into domain models before they spread into UI and service code.

For fetch clients

If your project uses the browser fetch API directly, wrap it once and make the wrapper generic where it helps.

async function request<TDto, TDomain>(
  input: RequestInfo,
  init: RequestInit,
  isValid: (value: unknown) => value is TDto,
  map: (dto: TDto) => TDomain,
): Promise<ApiResult<TDomain, string>> {
  const response = await fetch(input, init);
  const json = await getJson(response);

  if (!response.ok) {
    return { ok: false, status: response.status, error: "Request failed" };
  }

  if (!isValid(json)) {
    return { ok: false, status: response.status, error: "Invalid payload" };
  }

  return { ok: true, status: response.status, data: map(json) };
}

This pattern keeps generic usage focused and practical. If you want to sharpen your understanding of generics before building helpers like this, see Generics in TypeScript: Practical Patterns for Functions, APIs, and Components.

For teams with runtime validation

If you validate responses at runtime, your types and parsers can become your strongest API boundary. This is especially helpful for third-party APIs, webhook payloads, and long-lived frontend apps where backend changes may arrive independently.

Even then, keep the distinction between parsed transport data and domain data. Validation answers the question, “Is this payload structurally acceptable?” Mapping answers, “What shape does the rest of our app want to depend on?”

For error handling

Many API typing strategies focus only on success responses. That is a mistake. Error payloads deserve just as much clarity.

type ApiErrorCode = "UNAUTHORIZED" | "FORBIDDEN" | "NOT_FOUND" | "UNKNOWN";

type ApiError = {
  code: ApiErrorCode;
  message: string;
};

Then surface them consistently through your client layer. Typed errors make UI rendering, logging, retries, and support workflows much easier to reason about.

If your unions get hard to inspect, a refresher on narrowing patterns can help: TypeScript Narrowing Guide: typeof, in, instanceof, and Custom Type Guards.

Examples

Below are three concrete examples that show how the structure works in different API styles.

Example 1: REST list endpoint with pagination

type ProductDto = {
  id: string;
  name: string;
  price_cents: number;
};

type Product = {
  id: string;
  name: string;
  priceCents: number;
};

type ProductListDto = {
  items: ProductDto[];
  next_cursor: string | null;
};

type ProductList = {
  items: Product[];
  nextCursor: string | null;
};

function mapProduct(dto: ProductDto): Product {
  return {
    id: dto.id,
    name: dto.name,
    priceCents: dto.price_cents,
  };
}

function mapProductList(dto: ProductListDto): ProductList {
  return {
    items: dto.items.map(mapProduct),
    nextCursor: dto.next_cursor,
  };
}

This is straightforward, but the benefit is long term: your UI never needs to know the API uses next_cursor or price_cents.

Example 2: GraphQL operation-specific typing

type GetRepositoryQuery = {
  repository: {
    id: string;
    name: string;
    stars: number;
  } | null;
};

type RepositoryView = {
  id: string;
  name: string;
  stars: number;
  isPopular: boolean;
};

function mapRepository(
  data: GetRepositoryQuery["repository"],
): RepositoryView | null {
  if (!data) return null;

  return {
    id: data.id,
    name: data.name,
    stars: data.stars,
    isPopular: data.stars > 1000,
  };
}

Notice that the domain model includes a derived property. That is another reason not to let transport types leak everywhere.

Example 3: Generic fetch helper with endpoint-specific validators

async function fetchProfile(userId: string) {
  return request(
    `/api/users/${userId}`,
    { method: "GET" },
    isUserDto,
    mapUser,
  );
}

Callers now get a stable, typed result:

const result = await fetchProfile("123");

if (!result.ok) {
  console.error(result.error);
} else {
  console.log(result.data.fullName);
}

This is the kind of code that remains readable six months later. It also fits well with larger project structures in Node or monorepo setups; for those concerns, see Node.js with TypeScript: Project Structure, ESM vs CJS, and Build Setup and tsconfig.json Best Practices: Recommended Settings for Apps, Libraries, and Monorepos.

Common mistakes to avoid

  • Casting API responses immediately with as SomeType instead of narrowing or validating.
  • Sharing one giant type across request payloads, response payloads, UI models, and persistence models.
  • Ignoring error payloads and leaving them as unknown forever.
  • Overusing generics when simple named types would be easier to read.
  • Assuming GraphQL entity names imply identical shapes across operations.
  • Letting transport naming conventions leak into the rest of the application.

If you run into confusing compiler output while building these layers, a troubleshooting reference like TypeScript Error Guide: Common Compiler Errors and How to Fix Them can save time.

When to update

This topic is worth revisiting whenever your API surface, tooling, or publishing workflow changes. A response typing strategy that worked well for a small app may need revision once you add multiple services, generated clients, stricter validation, or shared package boundaries.

Review your approach when any of the following happens:

  • You introduce a new API style, such as adding GraphQL alongside REST.
  • You begin consuming more third-party APIs and need stronger runtime validation.
  • Your team adopts code generation from OpenAPI or GraphQL schemas.
  • You notice repeated as assertions around network calls.
  • Your domain models are drifting away from raw payload shapes.
  • Error handling is inconsistent across clients or apps.
  • Your React or Next.js data layer starts duplicating fetch and mapping logic.

A practical maintenance checklist looks like this:

  1. Audit the boundary: Find every place where raw JSON enters the app.
  2. Classify the types: Separate transport, validation, and domain models.
  3. Standardize results: Use a consistent success/error wrapper where appropriate.
  4. Add targeted validation: Start with the highest-risk endpoints first.
  5. Map intentionally: Normalize field names, nullability, and derived values in one place.
  6. Document conventions: Decide when to handwrite types and when to generate them.
  7. Refactor gradually: Do not rewrite the whole API layer at once unless the project is small.

If you want a compact refresher on the language features behind these patterns, keep a reference like TypeScript Cheat Sheet: Syntax, Utility Types, and Everyday Patterns nearby.

The long-term goal is simple: make external data feel predictable inside your codebase without pretending it was trustworthy from the start. If you do that, your fetch response TypeScript patterns, TypeScript REST API types, and GraphQL TypeScript types can evolve with your stack instead of fighting it.

Related Topics

#api#graphql#rest#data modeling#fetch#typescript
T

TypeScript Toolbox Editorial

Senior SEO Editor

Senior editor and content strategist. Writing about technology, design, and the future of digital media. Follow along for deep dives into the industry's moving parts.