A minimalistic TypeScript/JavaScript library for making type-safe API calls using a functional approach. Every API call returns either an APISuccess or APIFailure result - no exceptions to catch, just two clear outcomes to handle.
- Type-safe responses - Discriminated union pattern ensures compile-time safety
- No exception handling - API calls never throw; always return success or failure
- Retry support - Built-in retry mechanism with configurable intervals and error filtering
- Hook system - Register callbacks for request start, end, and retry events
- Response time tracking - Every response includes execution time in milliseconds
- Custom fetch support - Override the native fetch with your own implementation
npm install typesafe-api-callimport { APICaller, APIResponse, type APIRequest, APISuccess } from 'typesafe-api-call';
async function getUser(id: number): Promise<APIResponse<User, unknown>> {
const apiRequest: APIRequest = {
url: new URL(`https://api.example.com/users/${id}`),
method: 'GET'
};
return APICaller.call(apiRequest, (response: unknown) => {
// Return decoded User or null if decoding fails
return decodeUser(response);
});
}
const result = await getUser(1);
if (result instanceof APISuccess) {
console.log(result.response); // Type-safe User object
} else {
console.log(result.errorMessage); // Handle failure
}Makes a single API call and returns a typed response.
APICaller.call<SuccessResponse, ErrorResponse>(
apiRequest: APIRequest,
responseDecoder: ResponseDecoder<SuccessResponse>,
errorResponseDecoder?: ResponseDecoder<ErrorResponse> | null,
apiCaller?: FetchType
): Promise<APIResponse<SuccessResponse, ErrorResponse>>Parameters:
apiRequest- Request configuration (url, method, headers, body, etc.)responseDecoder- Function to decode successful responses. Returns decoded value ornullif decoding failserrorResponseDecoder- Optional function to decode error responsesapiCaller- Optional custom fetch implementation
Makes an API call with automatic retry on failure.
APICaller.callWithRetries<SuccessResponse, ErrorResponse>(
apiRequest: APIRequest,
responseDecoder: ResponseDecoder<SuccessResponse>,
retryConfig: RetryConfig,
errorResponseDecoder?: ResponseDecoder<ErrorResponse> | null,
apiCaller?: FetchType
): Promise<APIResponse<SuccessResponse, ErrorResponse>>RetryConfig:
type RetryConfig = {
maxRetries: number; // Number of retry attempts
retryInterval: number; // Delay between retries in milliseconds
retryOn: ErrorClass[]; // Error types that trigger a retry
};Example:
const response = await APICaller.callWithRetries(
apiRequest,
responseDecoder,
{
maxRetries: 3,
retryInterval: 2000,
retryOn: ['DOMException', 'TypeError', 'Error']
}
);Extends the native RequestInit interface with required fields:
interface APIRequest extends RequestInit {
url: URL; // Required: Request URL
method: HttpMethod; // Required: HTTP method
agent?: unknown; // Optional: For node-fetch compatibility
// ...plus all standard RequestInit options (headers, body, credentials, etc.)
}Supported HTTP Methods: GET, HEAD, POST, PUT, DELETE, PATCH
Returned when the API call succeeds and response decoding is successful.
class APISuccess<T> {
readonly statusCode: number; // HTTP status code (e.g., 200)
readonly status: string; // HTTP status text (e.g., "OK")
readonly response: T; // Decoded response body
readonly time: number; // Response time in milliseconds
}Returned when the API call fails or response decoding fails.
class APIFailure<E> {
readonly errorMessage: string; // Error description
readonly errorCode: number; // HTTP status code (-1 for exceptions)
readonly response: E | null; // Decoded error response (if decoder provided)
readonly errorResponse: unknown; // Raw error response
readonly errorDetails: ErrorDetails | null; // Detailed error info
readonly time: number; // Response time in milliseconds
}Provides detailed information about errors encountered during API calls.
type ErrorDetails = {
class: ErrorClass; // Error classification
name: string; // Error name
message: string | null; // Error message
cause: unknown; // Error cause
stack: string | null; // Stack trace
};
type ErrorClass =
| 'DOMException' // Network/abort errors
| 'TypeError' // Invalid request/URL errors
| 'DecodeFailure' // Response decoding failed
| 'InternalError' // Internal errors
| 'Error' // General errors
| 'UnhandledException'; // Unexpected errorsRegister callbacks to observe API call lifecycle events. Each hook requires a unique id to prevent duplicate registrations.
Triggered before an API call begins.
APICaller.registerStartHook({
id: 'my-start-hook',
func: (apiRequest: APIRequest) => {
console.log(`Starting: ${apiRequest.method} ${apiRequest.url.href}`);
}
});Triggered after an API call completes (success or failure).
APICaller.registerEndHook({
id: 'my-end-hook',
func: (apiRequest: APIRequest, response: APIResponse<unknown, unknown>) => {
const time = response instanceof APISuccess ? response.time : response.time;
console.log(`Completed in ${time}ms`);
}
});Triggered when a retry attempt is made (only with callWithRetries).
APICaller.registerRetryHook({
id: 'my-retry-hook',
func: (apiRequest: APIRequest, response: APIResponse<unknown, unknown>, attempt: number) => {
console.log(`Retry attempt #${attempt}`);
}
});The ResponseDecoder function validates and transforms raw API responses into typed objects:
type ResponseDecoder<T> = (rawResponse: unknown) => T | null;- Return the decoded value on success
- Return
nullif decoding/validation fails (triggersAPIFailurewithDecodeFailureerror class)
This pattern integrates well with validation libraries like type-decoder.
async function getAllPosts(): Promise<APIResponse<Post[], unknown>> {
const apiRequest: APIRequest = {
url: new URL('https://api.example.com/posts'),
method: 'GET'
};
return APICaller.call(apiRequest, (response: unknown) => {
return decodeArray(response, decodePost);
});
}async function createPost(post: Post): Promise<APIResponse<CreatePostResponse, unknown>> {
const apiRequest: APIRequest = {
url: new URL('https://api.example.com/posts'),
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(post)
};
return APICaller.call(apiRequest, decodeCreatePostResponse);
}const response = await APICaller.call(
apiRequest,
decodeSuccessResponse, // Decode success response
decodeErrorResponse // Decode error response if success decoding fails
);
if (response instanceof APISuccess) {
// response.response is typed as SuccessType
} else {
// response.response is typed as ErrorType | null
}When an API call fails, you receive an APIFailure instance. Here's how to interpret and handle different failure scenarios:
import { APICaller, APISuccess, APIFailure, type APIRequest } from 'typesafe-api-call';
const result = await APICaller.call(apiRequest, decodeUser);
if (result instanceof APIFailure) {
// Check what type of error occurred
const errorClass = result.errorDetails?.class;
switch (errorClass) {
case 'DOMException':
// Network error, request aborted, or CORS issue
console.log('Network error:', result.errorDetails?.message);
break;
case 'TypeError':
// Invalid URL or request configuration
console.log('Invalid request:', result.errorDetails?.message);
break;
case 'DecodeFailure':
// Response received but decoder returned null
// The raw response is available for inspection
console.log('Failed to decode response:', result.errorResponse);
break;
case 'InternalError':
case 'Error':
case 'UnhandledException':
// Other runtime errors
console.log('Unexpected error:', result.errorDetails?.message);
break;
default:
// HTTP error (4xx, 5xx) - errorDetails may be null
console.log(`HTTP ${result.errorCode}:`, result.errorMessage);
}
}For APIs that return structured error responses, use an error decoder to get typed error handling:
// Define your API's error response structure
type APIErrorResponse = {
code: string;
message: string;
details?: string[];
};
// Create a decoder for the error response
function decodeAPIError(raw: unknown): APIErrorResponse | null {
if (
typeof raw === 'object' &&
raw !== null &&
'code' in raw &&
'message' in raw &&
typeof (raw as APIErrorResponse).code === 'string' &&
typeof (raw as APIErrorResponse).message === 'string'
) {
return raw as APIErrorResponse;
}
return null;
}
// Use both success and error decoders
async function createUser(userData: UserInput): Promise<User | APIErrorResponse | null> {
const apiRequest: APIRequest = {
url: new URL('https://api.example.com/users'),
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData)
};
const result = await APICaller.call(
apiRequest,
decodeUser, // Success decoder
decodeAPIError // Error decoder
);
if (result instanceof APISuccess) {
return result.response; // Typed as User
}
// Handle typed error response
if (result.response !== null) {
// Error was successfully decoded - result.response is typed as APIErrorResponse
console.log(`API Error [${result.response.code}]: ${result.response.message}`);
return result.response;
}
// Decoding failed or network error - fall back to raw error info
console.log('Request failed:', result.errorMessage);
return null;
}type ValidationError = {
field: string;
message: string;
};
type ServerError = {
error: string;
validationErrors?: ValidationError[];
requestId?: string;
};
function decodeServerError(raw: unknown): ServerError | null {
if (typeof raw === 'object' && raw !== null && 'error' in raw) {
return raw as ServerError;
}
return null;
}
async function submitForm(formData: FormData) {
const result = await APICaller.call(
{ url: new URL('https://api.example.com/submit'), method: 'POST', body: formData },
decodeSuccessResponse,
decodeServerError
);
if (result instanceof APISuccess) {
console.log('Success! Completed in', result.time, 'ms');
return { success: true, data: result.response };
}
// APIFailure - determine the cause
const failure = result;
// Case 1: Server returned a structured error response
if (failure.response !== null) {
const serverError = failure.response;
if (serverError.validationErrors) {
// Handle validation errors
return {
success: false,
validationErrors: serverError.validationErrors
};
}
return {
success: false,
error: serverError.error,
requestId: serverError.requestId
};
}
// Case 2: Network or decoding error
if (failure.errorDetails) {
return {
success: false,
error: `${failure.errorDetails.class}: ${failure.errorDetails.message}`,
isNetworkError: ['DOMException', 'TypeError'].includes(failure.errorDetails.class)
};
}
// Case 3: HTTP error without structured response
return {
success: false,
error: failure.errorMessage,
httpStatus: failure.errorCode
};
}| Property | Type | Description |
|---|---|---|
errorMessage |
string |
Human-readable error description |
errorCode |
number |
HTTP status code, or -1 for exceptions |
response |
E | null |
Decoded error response (if error decoder provided and succeeded) |
errorResponse |
unknown |
Raw error response body |
errorDetails |
ErrorDetails | null |
Detailed error info for exceptions |
time |
number |
Response time in milliseconds |
Checkout more examples in the examples
- Clone the repository
- Install dependencies using
pnpm install - Run
pnpm run buildto build the library. - You can use examples/index.ts to test your changes.
- The library is open to all your suggestions & bugfixes and I would love to see your contributions.
- To contribute, kindly fork the repository & raise changes to the release branch.
The following libraries work well with typesafe-api-call:
- type-decoder: A library to decode and validate data types
- type-crafter: Generate types & decoders from YAML specifications
When combined, you can define types in YAML and auto-generate TypeScript types and decoders to use with typesafe-api-call.
Checkout the Example: YAML & Example: Generated Types
ISC