TidyURLs
Go to Tools
BLOG
The TidyURLsJournal

Practical guides, tutorials, and insights about URLs, SEO, web development, and online marketing. Learn, troubleshoot, and get the most of the web.

Back to the Journal
TidyURLs Journal

TypeScript Generics Explained Through API Response Types

Practical insights, clear explanations, and useful ideas for understanding the web.

October 26, 20266 min read5 topics
TypeScript Generics Explained Through API Response Types
Explore the details
#TypeScript#Generics#API#Type Safety#Pagination
October 26, 20266 min read

Generics let you describe API responses once while keeping the specific data type available wherever you use them. This hands-on example builds response and pagination types, shows how to constrain a type parameter, and explains why a generic type does not validate data received over the network.

Key Takeaways

  • A type parameter such as T lets a type or function work with different data types without discarding their specific information.
  • Use ApiResponse<User> or PaginatedResponse<User> to connect a reusable response shape to a concrete API model.
  • A constraint such as T extends { id: string } limits what callers can pass while preserving the full type of each item.
  • TypeScript types describe values at compile time; they do not check that a server’s JSON matches those types at runtime.

Define a generic API response

An API response often has a reusable shape: either it contains data or it contains an error. The type parameter T represents the type of the successful data.

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

type ApiResponse<T> =
  | { data: T; error: null }
  | { data: null; error: ApiError };

type User = {
  id: string;
  name: string;
  email: string;
};

The T in ApiResponse<T> is a type parameter. It acts like a placeholder that gets replaced with a specific type when you use the generic. For example, ApiResponse<User> means the success case contains a User; ApiResponse<User[]> means it contains an array of users.

The union makes the success and error cases distinguishable. Check error before using data:

function describeUserResponse(response: ApiResponse<User>): string {
  if (response.error !== null) {
    return `Request failed: ${response.error.message}`;
  }

  return `Loaded ${response.data.name}`;
}

After the check, TypeScript narrows the union: in the error branch, data is null; in the other branch, data is a User.

Add a generic pagination type

A paginated endpoint typically returns a list plus metadata. The item type is another type parameter, so the same pagination shape works for users, products, or other records.

type PaginatedResponse<T> = {
  items: T[];
  page: number;
  pageSize: number;
  totalItems: number;
  totalPages: number;
};

Combine the two generics to describe a paginated users endpoint:

type UsersPageResponse = ApiResponse<PaginatedResponse<User>>;

const usersPage: UsersPageResponse = {
  data: {
    items: [
      { id: "u-101", name: "Mina Chen", email: "mina@example.com" },
      { id: "u-102", name: "Omar Ali", email: "omar@example.com" }
    ],
    page: 1,
    pageSize: 2,
    totalItems: 8,
    totalPages: 4
  },
  error: null
};

Here, the nested type says that data is a successful response whose payload is a page of User objects. TypeScript can therefore check both the pagination fields and each item. Changing the endpoint model does not require a separate pagination definition: PaginatedResponse<Product> can reuse the same structure for products.

Preserve type information in generic functions

Generics are useful in functions, too. This function maps each item but keeps the page metadata. Its type parameters describe the item before and after the mapping:

function mapPage<T, U>(
  page: PaginatedResponse<T>,
  mapItem: (item: T) => U
): PaginatedResponse<U> {
  return {
    ...page,
    items: page.items.map(mapItem)
  };
}

For example, create a page of display labels from the user page:

const userLabels = mapPage(usersPage.data, (user) => `${user.name} <${user.email}>`);

// userLabels.items is string[]
// userLabels.totalPages is still number

TypeScript infers T as User from the input page and U as string from the callback. The result is a PaginatedResponse<string>, not an untyped object. You usually do not need to specify the type arguments yourself when they can be inferred from function arguments.

Use constraints when a function needs a particular property

A generic function can accept many types, but it cannot safely use properties that are not shared by those types. A constraint states what the type parameter must provide.

function indexById<T extends { id: string }>(items: readonly T[]): Map<string, T> {
  const result = new Map<string, T>();

  for (const item of items) {
    result.set(item.id, item);
  }

  return result;
}

const usersById = indexById(usersPage.data.items);

T extends { id: string } requires an id property of type string. The function can use that property, while the returned map still stores the complete original type T. For this call, the map values remain User objects, including name and email.

A constraint is not a replacement type. Avoid writing a function that accepts only { id: string } if callers also need the result to retain the rest of their model’s type.

Connect API types to network data carefully

A type annotation can describe the response your application expects, but it cannot prove that a server sent that shape. JSON is untrusted runtime data: an API may change, return an error document, or send a malformed value while the TypeScript code still compiles.

For example, this is a compile-time description, not runtime validation:

const response: ApiResponse<PaginatedResponse<User>> = usersPage;

When reading an actual response, treat the parsed body as unknown and validate it before using it as ApiResponse<PaginatedResponse<User>>. A schema validator or a carefully written parsing function can perform that check. Keep the generic type for the result of successful validation; do not use a type assertion as though it checked the server’s data.

The distinction is important: generics preserve relationships between types in your program, while validation checks values that arrive from outside it.

Common Mistakes

  • Using any for the payload: ApiResponse<any> allows unchecked property access and gives up much of the benefit of the generic. Use the real model type, or use unknown at an unvalidated data boundary.
  • Expecting a generic to validate JSON: ApiResponse<User> does not inspect a network response. Validate external data at runtime.
  • Forgetting the type argument: A generic type such as ApiResponse<T> needs a concrete type where it is used, unless TypeScript can infer one in a generic function call.
  • Over-constraining a function: Add only the properties the implementation needs. A broader constraint makes the function reusable while T preserves the caller’s full type.
  • Assuming the type argument is always the item type: In ApiResponse<PaginatedResponse<User>>, T for the outer response is the whole pagination object, not User.

Best Practices

  • Name type parameters for their role when a function has several, such as TItem and TResult; a short T is fine for a simple, familiar generic.
  • Model success and failure explicitly so callers can narrow the response safely.
  • Reuse generic containers for genuinely shared shapes, such as pagination, rather than duplicating nearly identical types.
  • Keep runtime validation at the boundary where data enters the application, then pass the validated, specifically typed value through the rest of the code.
  • Compile examples with strict checking enabled. For a project with TypeScript installed, run npx tsc --strict --noEmit path/to/file.ts and address errors instead of adding any to silence them.

Conclusion

Generics make API response types reusable without losing the detail of a particular payload. Combine response and pagination generics to describe nested API data, use constraints when an implementation needs a shared property, and validate network data separately because TypeScript types do not run at runtime.

References