Skip to main content

Overview

CallApi automatically detects and parses response bodies based on the Content-Type header. It supports JSON, text, and binary data with customizable parsing logic.

Response Types

responseType
'json' | 'text' | 'blob' | 'arrayBuffer' | 'formData' | 'stream'
Specifies how to parse the response body. If not provided, CallApi auto-detects based on Content-Type header.
result.ts
export type ResponseType = "blob" | "json" | "text";

export type ResponseTypeMap<TData> = {
  json: TData;
  text: string;
  blob: Blob;
  arrayBuffer: ArrayBuffer;
  formData: FormData;
  stream: ReadableStream<Uint8Array> | null;
};

Automatic Detection

CallApi automatically determines the response type from the Content-Type header:
result.ts
const JSON_REGEX = /^application\/(?:[\w!#$%&*.^`~-]*\+)?json(;.+)?$/i;

const textTypes = new Set([
  "image/svg",
  "application/xml",
  "application/xhtml",
  "application/html"
]);

const detectResponseType = (response: Response): ResponseType => {
  const initContentType = response.headers.get("content-type");
  
  if (!initContentType) {
    return "json"; // Default
  }
  
  const contentType = initContentType.split(";")[0] ?? "";
  
  if (JSON_REGEX.test(contentType)) {
    return "json";
  }
  
  if (textTypes.has(contentType) || contentType.startsWith("text/")) {
    return "text";
  }
  
  return "blob";
};
Detected types:
  • application/jsonjson
  • application/vnd.api+jsonjson
  • text/*text
  • image/svgtext
  • application/xmltext
  • Everything else → blob

JSON Parsing

Default JSON Parser

// Automatic JSON parsing
const { data } = await callApi<{ id: number; name: string }>("/api/user");
// Response: { "id": 1, "name": "John" }
// data is typed as { id: number; name: string }

// Explicit JSON type
const { data } = await callApi("/api/data", {
  responseType: "json"
});

Custom JSON Parser

responseParser
(text: string) => Promise<TData> | TData
Custom parser function for JSON responses. Receives response text and returns parsed data.
result.ts
export type ResponseParser<TData> = (text: string) => Awaitable<TData>;
Use cases: 1. Custom deserialization (e.g., superjson):
import superjson from "superjson";

const { data } = await callApi<{ date: Date }>("/api/event", {
  responseParser: (text) => superjson.parse(text)
});
// Properly deserializes Date objects
console.log(data.date instanceof Date); // true
2. Response transformation:
const { data } = await callApi("/api/data", {
  responseParser: (text) => {
    const parsed = JSON.parse(text);
    // Transform snake_case to camelCase
    return {
      userId: parsed.user_id,
      firstName: parsed.first_name,
      lastName: parsed.last_name
    };
  }
});
3. Handling non-standard JSON:
import JSON5 from "json5";

const { data } = await callApi("/api/config", {
  responseParser: (text) => JSON5.parse(text)
});
// Parses JSON with comments, trailing commas, etc.
4. Global custom parser:
import superjson from "superjson";

const client = createFetchClient({
  baseURL: "https://api.example.com",
  responseParser: (text) => superjson.parse(text)
});

// All responses use superjson
const { data } = await client("/api/event");

Text Parsing

const { data } = await callApi("/api/readme", {
  responseType: "text"
});
// data is string

// Auto-detected for text/plain
const { data } = await callApi("/api/log");
// Response Content-Type: text/plain
// data is string

Binary Data

Blob

// Download file as Blob
const { data } = await callApi("/api/download", {
  responseType: "blob"
});

// Create download link
const url = URL.createObjectURL(data);
const a = document.createElement("a");
a.href = url;
a.download = "file.pdf";
a.click();
URL.revokeObjectURL(url);

// Read as text
const text = await data.text();

// Read as ArrayBuffer
const buffer = await data.arrayBuffer();

ArrayBuffer

const { data } = await callApi("/api/binary", {
  responseType: "arrayBuffer"
});

// data is ArrayBuffer
const view = new Uint8Array(data);
console.log(view[0]);

Stream

const { data } = await callApi("/api/large-file", {
  responseType: "stream"
});

// data is ReadableStream<Uint8Array>
if (data) {
  const reader = data.getReader();
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    console.log("Received chunk:", value);
  }
}

FormData

const { data } = await callApi("/api/form", {
  responseType: "formData"
});

// data is FormData
const name = data.get("name");
const file = data.get("file") as File;

Result Modes

Control what data is returned from the API call:
resultMode
'all' | 'onlyData' | 'onlyResponse' | 'withoutResponse' | 'fetchApi'
default:"all"
Determines the shape of the returned result object.
result.ts
export type ResultModeMap<TData, TErrorData> = {
  all: { data: TData; error: null; response: Response } | ErrorVariant;
  onlyData: TData;
  onlyResponse: Response;
  withoutResponse: { data: TData; error: null } | Omit<ErrorVariant, "response">;
  fetchApi: Response;
};

all (default)

const result = await callApi("/api/user");

if (result.error) {
  console.error(result.error.message);
  console.log(result.response?.status);
} else {
  console.log(result.data);
  console.log(result.response.headers);
}

onlyData

const data = await callApi<User>("/api/user", {
  resultMode: "onlyData",
  throwOnError: true // Required with onlyData
});
// data is User
console.log(data.name);

onlyResponse

const response = await callApi("/api/user", {
  resultMode: "onlyResponse"
});
// response is Response
console.log(response.status);
console.log(response.headers.get("content-type"));

const data = await response.json();

withoutResponse

const result = await callApi("/api/user", {
  resultMode: "withoutResponse"
});

if (result.error) {
  console.error(result.error.message);
  // result.response is not available
} else {
  console.log(result.data);
  // result.response is not available
}

fetchApi

// Returns raw Response object
const response = await callApi("/api/user", {
  resultMode: "fetchApi"
});

// Identical to native fetch
if (response.ok) {
  const data = await response.json();
} else {
  console.error("Failed:", response.statusText);
}

Response Parsing Flow

From the source code:
result.ts
export const resolveResponseData = async (options: {
  response: Response;
  responseParser: ResponseParser<unknown>;
  responseType: ResponseType | null;
  resultMode: ResultModeType;
}) => {
  const { response, responseParser, responseType, resultMode } = options;
  
  // Skip parsing for fetchApi mode
  if (resultMode === "fetchApi") {
    return null;
  }
  
  const selectedParser = responseParser ?? JSON.parse;
  const selectedResponseType = responseType ?? detectResponseType(response);
  
  const RESPONSE_TYPE_LOOKUP = {
    json: async () => {
      const text = await response.text();
      return selectedParser(text);
    },
    text: () => response.text(),
    blob: () => response.blob(),
    arrayBuffer: () => response.arrayBuffer(),
    formData: () => response.formData(),
    stream: () => response.body
  };
  
  if (!Object.hasOwn(RESPONSE_TYPE_LOOKUP, selectedResponseType)) {
    throw new Error(`Invalid response type: ${selectedResponseType}`);
  }
  
  return RESPONSE_TYPE_LOOKUP[selectedResponseType]();
};

Advanced Examples

Handling Different Content Types

const { data, response } = await callApi("/api/resource");

const contentType = response.headers.get("content-type");

if (contentType?.includes("application/json")) {
  console.log("JSON data:", data);
} else if (contentType?.includes("text/")) {
  console.log("Text data:", data);
} else {
  console.log("Binary data:", data);
}

Download with Progress

const { data } = await callApi("/api/large-file", {
  responseType: "stream"
});

if (!data) throw new Error("No stream available");

const contentLength = parseInt(
  response.headers.get("content-length") ?? "0"
);

let receivedLength = 0;
const chunks: Uint8Array[] = [];

const reader = data.getReader();

while (true) {
  const { done, value } = await reader.read();
  
  if (done) break;
  
  chunks.push(value);
  receivedLength += value.length;
  
  const progress = (receivedLength / contentLength) * 100;
  console.log(`Progress: ${progress.toFixed(2)}%`);
}

const blob = new Blob(chunks);

Parse and Transform

type ApiResponse<T> = {
  success: boolean;
  data: T;
  meta: { timestamp: string };
};

const { data } = await callApi<User>("/api/user", {
  responseParser: (text) => {
    const response: ApiResponse<User> = JSON.parse(text);
    return response.data; // Extract nested data
  }
});
// data is User, not ApiResponse<User>

Clone Response for Multiple Reads

const { data, response } = await callApi("/api/data", {
  cloneResponse: true // Clone response before reading
});

// Can read response body again
const text = await response.text();
const blob = await response.blob(); // Works because response was cloned

Best Practices

Let Auto-Detection Work: Unless you have specific needs, let CallApi auto-detect the response type:
// Good - auto-detects based on Content-Type
const { data } = await callApi("/api/data");

// Only specify when necessary
const { data } = await callApi("/api/data", {
  responseType: "text" // Override auto-detection
});
Response Body Can Only Be Read Once: Unless you clone the response, you can only read the body once:
// Bad - will throw error
const { data, response } = await callApi("/api/data");
const text = await response.text(); // Error: body already read

// Good - clone response first
const { data, response } = await callApi("/api/data", {
  cloneResponse: true
});
const text = await response.text(); // Works
Custom Parsers for Special Formats: Use custom parsers for non-standard JSON or special deserialization needs:
import superjson from "superjson";

const client = createFetchClient({
  responseParser: (text) => superjson.parse(text)
});

Build docs developers (and LLMs) love