n4nAI

Narrowing LLM error responses with TypeScript type guards

Learn how to build TypeScript type guards for LLM error responses to safely narrow API failures and handle provider-specific errors in code.

n4n Team3 min read677 words

Audio narration

Coming soon — every post will get a voice note here.

LLM APIs fail in ways typical REST clients don’t prepare you for: rate limits, content filters, provider outages, and malformed streams all surface as ambiguous 4xx or 5xx bodies. Applying typescript type guards llm errors turns those loosely-typed fetch rejections into precise, exhaustively-checked unions so your retry and fallback logic can act instead of guessing. This guide walks through building a typed error layer for an OpenAI-compatible client, from raw response shapes to guarded call sites.

What you’re dealing with

OpenAI-compatible endpoints return a consistent JSON envelope on failure, but the type and code fields are under-specified in TypeScript land. Most SDKs type the error as any or a generic Error with a stringified body. That forces you to parse strings at runtime and cast. If you proxy through a gateway, the envelope may gain fields like provider or upstream_status. Without narrowing, your catch block becomes a pile of if (e.message.includes('rate')) checks that break when the vendor rewords a message.

Typescript type guards llm errors solve this by letting the compiler know what shape an unknown value actually has after a runtime check. You write a function that returns v is SpecificError, and the type narrows inside the if block.

Step 1: Define the error response shapes you actually get

Start by capturing the real JSON. A minimal OpenAI-compatible error looks like this:

{
  "error": {
    "message": "Rate limit reached for requests",
    "type": "rate_limit_error",
    "code": "rate_limit_exceeded",
    "param": null
  }
}

Translate that into discriminated interfaces. Use a literal union for type so later guards can narrow on it.

export type LLMErrorType =
  | 'rate_limit_error'
  | 'invalid_request_error'
  | 'authentication_error'
  | 'server_error'
  | 'content_filter_error';

export interface BaseLLMError {
  error: {
    message: string;
    type: LLMErrorType;
    code?: string;
    param?: string | null;
  };
}

Do not use any for code. Keep it optional string. If you know your provider sends numeric codes, adjust accordingly.

Step 2: Write primitive type guards for the base shape

A guard needs to validate the unknown blob before asserting the type. Build a tiny isObject helper, then the base guard.

function isObject(v: unknown): v is Record<string, unknown> {
  return typeof v === 'object' && v !== null;
}

export function isBaseLLMError(v: unknown): v is BaseLLMError {
  if (!isObject(v)) return false;
  const err = v.error;
  if (!isObject(err)) return false;
  return (
    typeof err.message === 'string' &&
    typeof err.type === 'string' &&
    (err.code === undefined || typeof err.code === 'string')
  );
}

This rejects malformed bodies and lets you trust v.error.message downstream. The guard returns a boolean but carries the type predicate, so TypeScript narrows call sites.

Step 3: Narrow provider-specific variants with discriminated unions

You rarely want to treat all errors the same. Rate limits should retry with backoff; auth errors should fail fast. Generate per-type guards from the base guard.

function makeGuard(t: LLMErrorType) {
  return (v: unknown): v is BaseLLMError & { error: { type: typeof t } } =>
    isBaseLLMError(v) && v.error.type === t;
}

export const isRateLimitError = makeGuard('rate_limit_error');
export const isAuthError = makeGuard('authentication_error');
export const isContentFilterError = makeGuard('content_filter_error');

Now if (isRateLimitError(body)) narrows body.error.type to 'rate_limit_error'. You can switch on the type with exhaustiveness checking if you enable noImplicitReturns and a default that throws.

Step 4: Guard against transport and network failures

Fetch rejects with a TypeError on DNS failure, offline, or CORS issues—not with a JSON body. Wrap the call so network errors become a distinct branded type.

export interface NetworkError {
  __brand: 'network';
  cause: Error;
}

export function isNetworkError(e: unknown): e is NetworkError {
  return (
    e instanceof Error &&
    (e.name === 'TypeError' || e.name === 'AbortError')
  );
}

In strict mode, instanceof Error is the only reliable cross-realm check; for worker/iframe edges, also check typeof e?.message === 'string'.

Step 5: Compose guards in your API client wrapper

Put the guards to work in a single function that performs the request and throws typed errors. This is the only place you touch unknown.

export class LLMClientError extends Error {
  constructor(
    public kind: 'network' | 'rate_limit' | 'auth' | 'api' | 'unknown',
    public detail: unknown
  ) {
    super(typeof detail === 'string' ? detail : 'LLM request failed');
  }
}

async function chatCompletion(req: ChatRequest): Promise<ChatResponse> {
  let res: Response;
  try {
    res = await fetch('https://api.example.com/v1/chat/completions', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify(req),
    });
  } catch (e) {
    if (isNetworkError(e)) throw new LLMClientError('network', e);
    throw e;
  }

  if (!res.ok) {
    const body: unknown = await res.json().catch(() => null);
    if (isRateLimitError(body)) throw new LLMClientError('rate_limit', body);
    if (isAuthError(body)) throw new LLMClientError('auth', body);
    if (isBaseLLMError(body)) throw new LLMClientError('api', body);
    throw new LLMClientError('unknown', { status: res.status });
  }

  return (await res.json()) as ChatResponse;
}

Callers now catch LLMClientError and switch on kind. No any leaks past the wrapper.

Step 6: Handle gateway fallback and upstream fields

If you route through a gateway such as n4n.ai, which provides an OpenAI-compatible endpoint with automatic fallback when a provider is degraded, the error envelope may include provider and upstream_status. Extend the guard so your retry logic can decide whether the fallback already happened.

export interface GatewayLLMError extends BaseLLMError {
  error: BaseLLMError['error'] & {
    provider?: string;
    upstream_status?: number;
  };
}

export function isGatewayLLMError(v: unknown): v is GatewayLLMError {
  if (!isBaseLLMError(v)) return false;
  const e = v.error as Record<string, unknown>;
  return typeof e.provider === 'string' || typeof e.upstream_status === 'number';
}

Use it after the base checks:

if (isGatewayLLMError(body)) {
  console.warn(`Upstream ${body.error.provider} failed with ${body.error.upstream_status}`);
}

This keeps your client agnostic but observable.

Step 7: Test guards against real fixtures

Guards are pure functions; test them with copied production responses. Use Vitest or Jest.

import { isRateLimitError, isBaseLLMError } from './guards';

const rateLimitFixture = {
  error: {
    message: 'Rate limit reached',
    type: 'rate_limit_error',
    code: 'rate_limit_exceeded',
  },
};

const authFixture = {
  error: { message: 'Invalid key', type: 'authentication_error' },
};

test('narrows rate limit', () => {
  expect(isRateLimitError(rateLimitFixture)).toBe(true);
  expect(isRateLimitError(authFixture)).toBe(false);
  expect(isBaseLLMError(rateLimitFixture)).toBe(true);
});

Run with:

npx vitest run guards.test.ts

A green suite means your typescript type guards llm errors match reality. When a provider adds a field, the test fixtures break first, not your production retry path.

Verify success

You have a working typed error layer when: (1) tsc --strict passes with zero any in your client code, (2) a forced 429 response triggers the rate_limit branch without a cast, and (3) a pulled network cable throws LLMClientError with kind network. Hit a local mock that returns each error shape, or use a proxy to inject faults. If your switch on kind is exhaustive and the compiler agrees, you are done.

Typescript type guards llm errors are not glamorous, but they are the difference between a bot that silently drops requests and one that degrades predictably. Write the guards once, test them against fixtures, and let the compiler enforce the rest.

Tagstypescripttype-guardserror-handlingllm-api

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All typescript typed llm api clients posts →