StackDevLife
Stop using `any` — here's how to type your messy API responses correctly
Back to Blog

Stop using `any` — here's how to type your messy API responses correctly

You cast the API response to `any` to stop TypeScript complaining. It worked. Now a field changed shape three months later and the bug is in production. Here's how to type messy API responses properly — with real validation, not just type assertions.

SB

Sandeep Bansod

February 21, 202610 min read
Share:

You've done it. Everyone has. The API returns something, TypeScript starts complaining, and the fastest fix is as any. The error goes away. You move on. Six weeks later, that field is null instead of a string, the bug is in production, and TypeScript said nothing — because you told it not to.

The problem is not that API responses are messy. They are, and always will be. The problem is that any doesn't model that mess — it erases it. You lose the one thing TypeScript is there to give you: a guarantee that your code matches your data.

This article covers the real techniques — from unknown to type guards to Zod to a fully typed fetch wrapper — that let you handle unpredictable API shapes without lying to the compiler.

The Real Problem with any

When you write const data = response.json() as any, TypeScript stops checking entirely. Every property access, every method call, every nested field — unchecked. You could write data.user.address.city.toUpperCase() and TypeScript would nod along even if address is undefined.

TypeScript
// This compiles. It will blow up at runtime.
const res  = await fetch('/api/user/1');
const data = (await res.json()) as any;

console.log(data.profile.avatar.url.trim());
//                       ^^^^^^ could be null
//                              ^^^ could be undefined
//                                  ^^^ TypeScript: fine by me

The worse version is as unknown as YourType — the double cast. It is exactly as dangerous as any, just more words. You're not adding safety, you're just suppressing a different error with extra ceremony.

TypeScript
// Still no actual validation — just a louder lie
const user = (await res.json()) as unknown as User;
console.log(user.email.toLowerCase()); // runtime error if email is null

The real issue is that fetch returns Promise<any>. The TypeScript standard library types Response.json() as returning any because it genuinely cannot know the shape. That's the compiler being honest. When you cast that any to your type, you're being dishonest on its behalf.

Your First Tool: unknown Instead of any

unknown is the honest version of any. It tells TypeScript: "I have a value and I don't know its shape yet." The critical difference is that TypeScript forces you to prove the shape before you use it. You cannot access properties on unknown without narrowing it first.

TypeScript
const res  = await fetch('/api/user/1');
const data: unknown = await res.json();

// TypeScript blocks this immediately
console.log(data.email);
// Error: Object is of type 'unknown'

// You must narrow it first
if (
  typeof data === 'object' &&
  data !== null &&
  'email' in data &&
  typeof (data as any).email === 'string'
) {
  console.log(data.email); // TypeScript: now it's safe
}

This is the right instinct but the wrong implementation for anything larger than a toy example. Writing inline narrowing for a 20-field API response is unreadable and unmaintainable. That's what type guards and schema validators are for.

Type Guards — Validate at the Boundary

A type guard is a function that takes an unknown value, validates its shape at runtime, and tells TypeScript what it is. The return type value is YourType is the key — it's called a type predicate, and it narrows the type inside any if block that uses it.

TypeScript
// types/user.ts
export interface User {
  id:        number;
  email:     string;
  name:      string;
  role:      'admin' | 'editor' | 'viewer';
  createdAt: string;
}

// guards/isUser.ts
export function isUser(value: unknown): value is User {
  return (
    typeof value === 'object' &&
    value !== null &&
    typeof (value as Record<string, unknown>).id        === 'number' &&
    typeof (value as Record<string, unknown>).email     === 'string' &&
    typeof (value as Record<string, unknown>).name      === 'string' &&
    ['admin', 'editor', 'viewer'].includes(
      (value as Record<string, unknown>).role as string
    )
  );
}

Now use it at the API boundary — the one place in your codebase where external data enters:

TypeScript
import { isUser } from '../guards/isUser';

export async function fetchUser(id: number): Promise<User> {
  const res  = await fetch(`/api/users/${id}`);
  const data: unknown = await res.json();

  if (!isUser(data)) {
    throw new Error(`Unexpected response shape from /api/users/${id}`);
  }

  // TypeScript knows `data` is User from here on
  return data;
}

Everything downstream of fetchUser works with a real User type — fully typed, autocompleted, checked on every property access. The mess is contained to the guard function.

The downside: writing guards by hand is verbose and easy to get wrong. If you add a field to the User interface, you have to remember to update the guard. If you don't, the guard silently misses the new field. For large or frequently changing APIs, use Zod.

Zod — Runtime Validation That Generates Types

Zod is a schema declaration library that solves the gap between your TypeScript type and your runtime validation. Instead of maintaining both a type and a guard separately, you define one schema and Zod generates the type from it automatically.

Bash
npm install zod
TypeScript
import { z } from 'zod';

// Define once — Zod generates the type AND validates at runtime
const UserSchema = z.object({
  id:        z.number(),
  email:     z.string().email(),
  name:      z.string().min(1),
  role:      z.enum(['admin', 'editor', 'viewer']),
  createdAt: z.string().datetime(),
});

// Type is inferred automatically — no separate interface needed
export type User = z.infer<typeof UserSchema>;

Now the fetch function becomes clean and fully safe:

TypeScript
export async function fetchUser(id: number): Promise<User> {
  const res  = await fetch(`/api/users/${id}`);
  const data: unknown = await res.json();

  // .parse() throws a ZodError with a detailed message if validation fails
  // .safeParse() returns { success, data, error } — use this in production
  const result = UserSchema.safeParse(data);

  if (!result.success) {
    console.error('Validation failed:', result.error.flatten());
    throw new Error(`Invalid response from /api/users/${id}`);
  }

  return result.data; // Fully typed User
}

result.error.flatten() gives you a structured breakdown of exactly which fields failed and why — far more useful than a generic "invalid response" message when you're debugging at 2AM.

The key difference from a type guard: if you add phoneNumber: z.string().optional() to UserSchema, the User type updates automatically. Schema and type are always in sync because they're the same thing.

Typing Partial and Nullable API Fields

Real APIs are messy. Fields are sometimes null, sometimes missing entirely, sometimes a string and sometimes a number depending on context. Zod handles all of these explicitly.

TypeScript
const OrderSchema = z.object({
  id:          z.number(),
  status:      z.enum(['pending', 'shipped', 'delivered', 'cancelled']),

  // nullable — the API returns null when not yet assigned
  assignedTo:  z.string().nullable(),

  // optional — the field may not be present at all
  couponCode:  z.string().optional(),

  // nullable AND optional — null or missing are both valid
  notes:       z.string().nullable().optional(),

  // coerce a string date to a Date object automatically
  shippedAt:   z.coerce.date().nullable(),

  // API returns strings for monetary values — validate and keep as string
  total:       z.string().regex(/^\d+\.\d{2}$/),

  // nested object
  address: z.object({
    line1:    z.string(),
    line2:    z.string().optional(),
    city:     z.string(),
    postcode: z.string(),
    country:  z.string().length(2), // ISO 3166-1 alpha-2
  }),

  // array of nested objects
  items: z.array(z.object({
    sku:      z.string(),
    quantity: z.number().int().positive(),
    price:    z.string(),
  })),
});

export type Order = z.infer<typeof OrderSchema>;

The generated Order type reflects all of this precisely — assignedTo: string | null, couponCode?: string, notes?: string | null | undefined. TypeScript will force you to handle the null and undefined cases. Not because you asked it to — because the schema told it to.

Handling API Errors Correctly

Most typed fetch examples only type the happy path. Real APIs return structured error objects too, and those have shapes worth validating.

TypeScript
// The error shape your API returns on 4xx / 5xx
const ApiErrorSchema = z.object({
  code:    z.string(),            // e.g. 'USER_NOT_FOUND'
  message: z.string(),
  details: z.record(z.string()).optional(), // field-level errors
});

export type ApiError = z.infer<typeof ApiErrorSchema>;

// A typed result type — no throwing unless truly unexpected
export type ApiResult<T> =
  | { ok: true;  data:  T        }
  | { ok: false; error: ApiError };

export async function fetchUser(id: number): Promise<ApiResult<User>> {
  const res  = await fetch(`/api/users/${id}`);
  const body: unknown = await res.json();

  if (!res.ok) {
    const errResult = ApiErrorSchema.safeParse(body);
    return {
      ok:    false,
      error: errResult.success
        ? errResult.data
        : { code: 'UNKNOWN', message: 'Unexpected error shape' },
    };
  }

  const result = UserSchema.safeParse(body);
  if (!result.success) {
    return { ok: false, error: { code: 'INVALID_SHAPE', message: 'Response schema mismatch' } };
  }

  return { ok: true, data: result.data };
}

Now at the call site, TypeScript forces you to handle both cases. No unchecked exceptions, no silent failures:

TypeScript
const result = await fetchUser(42);

if (!result.ok) {
  // result.error is typed as ApiError
  showToast(result.error.message);
  return;
}

// result.data is typed as User — fully safe from here
updateProfile(result.data);

Generic Fetch Wrapper with Full Type Safety

Once you have schemas, you can write one typed fetch utility and use it everywhere instead of duplicating the validation logic in every service function.

TypeScript
import { z, ZodSchema } from 'zod';

export type FetchResult<T> =
  | { ok: true;  data:  T      }
  | { ok: false; error: string; status: number };

export async function typedFetch<T>(
  url:     string,
  schema:  ZodSchema<T>,
  options?: RequestInit,
): Promise<FetchResult<T>> {
  let res: Response;

  try {
    res = await fetch(url, options);
  } catch (err) {
    return { ok: false, error: 'Network error', status: 0 };
  }

  const body: unknown = await res.json().catch(() => null);

  if (!res.ok) {
    const message = typeof body === 'object' && body !== null && 'message' in body
      ? String((body as Record<string, unknown>).message)
      : `HTTP ${res.status}`;
    return { ok: false, error: message, status: res.status };
  }

  const parsed = schema.safeParse(body);
  if (!parsed.success) {
    console.error('[typedFetch] schema mismatch:', parsed.error.flatten());
    return { ok: false, error: 'Invalid response shape', status: res.status };
  }

  return { ok: true, data: parsed.data };
}

Using it across your app:

TypeScript
// Any endpoint — just pass the schema
const userResult = await typedFetch(`/api/users/${id}`, UserSchema);
const orderResult = await typedFetch(`/api/orders/${id}`, OrderSchema);

// POST with body
const createResult = await typedFetch(
  '/api/users',
  UserSchema,
  {
    method:  'POST',
    headers: { 'Content-Type': 'application/json' },
    body:    JSON.stringify({ email: 'user@example.com', name: 'Riya' }),
  },
);

if (createResult.ok) {
  // createResult.data is User — fully typed
  console.log('Created:', createResult.data.email);
}

The schema is the only thing that changes between endpoints. All error handling, network error recovery, and logging lives in one place.

When the API Shape Changes Without Warning

Third-party APIs change. Internal APIs change. The backend team renames a field and forgets to tell you. With any, this breaks silently at runtime. With Zod, the safeParse fails immediately with a clear error message pointing to the exact field.

Two Zod tools are useful here:

z.object().passthrough() — validates the fields you defined but allows extra unknown fields through. Useful when the API adds fields you don't need yet, without breaking validation:

TypeScript
// Extra fields from the API are allowed through — your type stays clean
const UserSchema = z.object({
  id:    z.number(),
  email: z.string(),
  name:  z.string(),
}).passthrough();

z.object().strip() (the default) — strips unknown fields. Safer for security-sensitive contexts where you don't want unexpected data flowing through your app.

For APIs that evolve, use .catch() to provide fallback values for non-critical fields instead of failing the whole parse:

TypeScript
const UserSchema = z.object({
  id:     z.number(),
  email:  z.string(),
  name:   z.string(),

  // If the API stops sending `avatarUrl`, fall back to null
  // instead of throwing a validation error
  avatarUrl: z.string().nullable().catch(null),

  // If `theme` is an unexpected value, default to 'light'
  theme: z.enum(['light', 'dark']).catch('light'),
});

This gives you a resilient schema — strict on the fields that matter, graceful on the fields that might drift.

Common Mistakes

  • Using as YourType instead of actually validating — a type assertion tells TypeScript what you believe the shape is, not what it actually is. If you're wrong, TypeScript cannot help you
  • Validating in components instead of at the fetch boundary — validation belongs in your service or API layer, not scattered across UI components. Components should receive already-validated, fully-typed data
  • Marking everything optional to avoid validation failures — z.string().optional() on a required field means you'll get undefined where you don't expect it. Be strict on required fields; only use optional where the API contract genuinely allows absence
  • Using .parse() instead of .safeParse() in production code — .parse() throws a ZodError on failure, which means unhandled promise rejections unless you wrap every call in try/catch. Use .safeParse() and handle the error branch explicitly
  • Writing a separate interface and a separate guard — this is the pre-Zod pattern that created the sync problem in the first place. With Zod, z.infer<typeof Schema> is your type. One source of truth

The Takeaway

any doesn't solve the problem of messy API responses — it hides it until runtime. unknown forces you to be explicit. Type guards add reusable validation. Zod makes schema and type the same thing so they can never go out of sync. A generic typedFetch wrapper brings it all together so validation happens once, consistently, at the boundary where external data enters your app. After that, the rest of your codebase works with real types — and TypeScript can actually protect you.

SB

Sandeep Bansod

I'm a Front‑End Developer located in India focused on website look great, work fast and perform well with a seamless user experience. Over the years I worked across different areas of digital design, web development, email design, app UI/UX and developemnt.

Related Articles

You might also enjoy these

Stay in the loop

Get articles on technology, health, and lifestyle delivered to your inbox.No spam — unsubscribe anytime.