Skip to main content
AI
7 min read
February 1, 2026

Generating TypeScript Types from API Responses with AI

The Type Safety Gap Between Frontend and Backend

Segev Sinay

Segev Sinay

Frontend Architect

Share:

The Type Safety Gap Between Frontend and Backend

Here is a scenario I see on every project where frontend and backend teams work separately: the backend team ships a new endpoint, sends the frontend team a Slack message saying "here is the response format," and includes a JSON example. The frontend developer looks at the JSON, guesses the types, writes an interface with a few optional fields "just in case," and moves on.

Three weeks later, the backend changes the response shape slightly — a field that was a string is now an object, or a field that was always present is now sometimes null. The frontend types do not match, but TypeScript does not catch it because the types were wrong to begin with. The bug ships to production.

I have a process that eliminates this gap entirely using AI. It takes about 15 minutes to set up and saves hours of debugging over the life of a project.

Step 1: Capture Real API Responses (5 Minutes)

Start with actual data, not documentation. Documentation lies. API responses do not.

Open your app's network tab and capture real responses from every endpoint your frontend uses. Or use curl:

# Capture responses from your API
curl -s -H "Authorization: Bearer $TOKEN" \
  http://localhost:3001/api/users/me | jq . > responses/user-me.json

curl -s -H "Authorization: Bearer $TOKEN" \
  http://localhost:3001/api/dashboard/stats | jq . > responses/dashboard-stats.json

curl -s -H "Authorization: Bearer $TOKEN" \
  http://localhost:3001/api/invoices?page=1&limit=10 | jq . > responses/invoices-list.json

Capture at least 3-5 responses per endpoint to see variations. Include responses with empty arrays, null fields, and error states.

Step 2: Generate Types from Real Responses (10 Minutes)

Feed the responses to Claude Code with this prompt:

Here are real API responses from our backend. Generate TypeScript interfaces
for each endpoint.

Endpoint: GET /api/users/me
Response 1 (active user): [paste JSON]
Response 2 (trial user): [paste JSON]
Response 3 (deactivated user): [paste JSON]

Endpoint: GET /api/dashboard/stats
Response 1 (with data): [paste JSON]
Response 2 (new user, empty data): [paste JSON]

Requirements:
1. Use strict TypeScript — no "any", no implicit optional fields
2. Mark fields as optional ONLY if they are actually missing in some responses
3. Use union types for fields that can be different types (string | null)
4. Use enums or literal unions for fields with known values (status: "active" | "trial" | "deactivated")
5. Nest interfaces properly — don't flatten nested objects
6. Add JSDoc comments describing each field
7. Name interfaces with a consistent pattern: {Resource}Response, {Resource}Item
8. Include the pagination wrapper type if responses are paginated
9. Export all interfaces from a single file: src/types/api.ts

Also generate a type guard function for each response type that validates
the shape at runtime (useful for API response validation).

Here is what good generated types look like:

// src/types/api.ts

/** User profile response from GET /api/users/me */
export interface UserResponse {
  /** Unique user identifier */
  id: string;
  /** User's email address */
  email: string;
  /** Display name, null if not set */
  displayName: string | null;
  /** URL to user's avatar image */
  avatarUrl: string;
  /** Current subscription status */
  status: 'active' | 'trial' | 'deactivated' | 'suspended';
  /** Subscription plan details */
  plan: UserPlan;
  /** Account creation timestamp (ISO 8601) */
  createdAt: string;
  /** Last login timestamp, null if never logged in */
  lastLoginAt: string | null;
}

export interface UserPlan {
  /** Plan identifier */
  id: string;
  /** Human-readable plan name */
  name: 'Free' | 'Pro' | 'Enterprise';
  /** Maximum number of projects allowed */
  maxProjects: number;
  /** Whether the plan includes AI features */
  aiEnabled: boolean;
  /** Trial end date, only present for trial status */
  trialEndsAt?: string;
}

/** Paginated response wrapper */
export interface PaginatedResponse<T> {
  data: T[];
  pagination: {
    page: number;
    limit: number;
    total: number;
    totalPages: number;
  };
}

/** Type guard for UserResponse */
export function isUserResponse(obj: unknown): obj is UserResponse {
  return (
    typeof obj === 'object' &&
    obj !== null &&
    'id' in obj &&
    'email' in obj &&
    'status' in obj &&
    typeof (obj as UserResponse).id === 'string' &&
    typeof (obj as UserResponse).email === 'string' &&
    ['active', 'trial', 'deactivated', 'suspended'].includes(
      (obj as UserResponse).status
    )
  );
}

Step 3: Generate API Service Functions with Types (10 Minutes)

Now create typed API service functions that use these interfaces:

Using the TypeScript interfaces we just generated, create an API service
layer at src/services/api/.

For each endpoint, create a function that:
1. Accepts typed parameters (query params, body, path params)
2. Returns a typed Promise with the correct response interface
3. Uses axios or fetch with proper error handling
4. Includes request/response interceptors for auth tokens

File structure:
- src/services/api/userApi.ts — user-related endpoints
- src/services/api/dashboardApi.ts — dashboard endpoints
- src/services/api/invoiceApi.ts — invoice endpoints
- src/services/api/client.ts — shared axios instance with interceptors

Example function signature:
export async function getUser(): Promise<UserResponse>
export async function getDashboardStats(dateRange: DateRange): Promise<DashboardStatsResponse>
export async function getInvoices(params: InvoiceListParams): Promise<PaginatedResponse<InvoiceItem>>

The result is a fully typed API layer where TypeScript will catch mismatches between what the frontend expects and what the API returns — at compile time, not at runtime.

Step 4: Generate Zod Schemas for Runtime Validation (10 Minutes)

TypeScript types disappear at runtime. For critical endpoints, add runtime validation with Zod:

For each TypeScript interface in src/types/api.ts, generate a
corresponding Zod schema in src/schemas/api.ts.

The Zod schemas should:
1. Match the TypeScript interfaces exactly
2. Include .transform() for any data transformations (e.g., date strings to Date objects)
3. Include .default() for fields that should have fallback values
4. Include .refine() for business rules (e.g., email must be valid format)

Also create a validated fetch wrapper:

export async function fetchValidated<T>(
  url: string,
  schema: z.ZodType<T>,
  options?: RequestInit
): Promise<T> {
  const response = await fetch(url, options);
  const data = await response.json();
  return schema.parse(data); // Throws if response doesn't match schema
}

Usage example:
const user = await fetchValidated('/api/users/me', UserResponseSchema);
// user is fully validated AND typed

This catches backend changes the moment they happen, not when users report bugs.

Step 5: Set Up Type Sync for Ongoing Maintenance (5 Minutes)

Types drift over time. Set up a script that catches drift early:

#!/bin/bash
# scripts/validate-api-types.sh
# Run this in CI or before each release

echo "Validating API response types..."

# Start the backend locally (or use staging)
API_URL=${API_URL:-"http://localhost:3001"}

# Test each endpoint against its Zod schema
npx ts-node scripts/validate-schemas.ts "$API_URL"
// scripts/validate-schemas.ts
import { UserResponseSchema, DashboardStatsResponseSchema } from '../src/schemas/api';

const endpoints = [
  { url: '/api/users/me', schema: UserResponseSchema },
  { url: '/api/dashboard/stats', schema: DashboardStatsResponseSchema },
];

async function validateAll(baseUrl: string) {
  for (const { url, schema } of endpoints) {
    try {
      const response = await fetch(`${baseUrl}${url}`, {
        headers: { Authorization: `Bearer ${process.env.TEST_TOKEN}` },
      });
      const data = await response.json();
      schema.parse(data);
      console.log(`PASS: ${url}`);
    } catch (error) {
      console.error(`FAIL: ${url}`, error);
      process.exit(1);
    }
  }
}

validateAll(process.argv[2]);

Why This Matters More Than You Think

On one client project, I implemented this process and discovered that 7 out of 23 endpoints had response types that did not match the actual API. In three cases, the frontend was silently dropping data because the types said a field was a string when the backend was returning an object. In one case, the app crashed for users with null values in a field the types declared as required.

All of these bugs had been in production for months. Type generation from real responses would have caught every single one of them on day one.

The process takes about an hour to set up for an existing project and 15 minutes for a new project. After that, it runs automatically and catches every type mismatch before it reaches users.

AI
React
Productivity
TypeScript
Prompt Engineering
Debugging
Build Tools

Related Articles

Contact

Let’s Connect

Have a question or an idea? I’d love to hear from you.

Send a Message