@iamankan/react-api-client
v0.1.1
Published
A typed API client factory for defining and calling HTTP endpoints
Downloads
272
Maintainers
Readme
@iamankan/react-api-client
A typed API client factory for defining and calling HTTP endpoints with full TypeScript type safety, built on top of Axios.
Overview
@iamankan/react-api-client lets you define your API surface once — with full request/response/parameter types — and then consume it anywhere in your app with zero runtime overhead and complete IDE autocomplete.
Why use this library?
- Type-safe by default — TypeScript infers request body shapes, query params, and response types from your definitions
- No code generation — types are defined inline with your endpoints
- Composable — separate concerns cleanly: define endpoints, configure the HTTP client, assemble the API
- Built on Axios — supports all Axios config options (headers, interceptors, signal, etc.)
- Tiny footprint — no runtime schema validation, no magic
Installation
npm install @iamankan/react-api-client axios
# or
yarn add @iamankan/react-api-client axios
# or
pnpm add @iamankan/react-api-client axiosNote:
axiosis a peer dependency and must be installed separately.
Quick Start
import { defineEndpoint, createApiClient, createApi } from "@iamankan/react-api-client";
// 1. Define your types
type User = { id: string; name: string; email: string };
// 2. Define endpoints
const endpoints = {
users: {
list: defineEndpoint<User[]>("/users"),
create: defineEndpoint<User, { name: string; email: string }>("/users", { method: "POST" }),
},
};
// 3. Create the HTTP caller
const httpCaller = createApiClient({
baseURL: "https://api.example.com/v1",
});
// 4. Assemble the API client
export const api = createApi(endpoints, httpCaller);
// 5. Call endpoints — fully typed
const users = await api.users.list();
// users: User[]
const newUser = await api.users.create({ data: { name: "Alice", email: "[email protected]" } });
// newUser: UserCore Concepts
The library follows a three-step factory pattern:
defineEndpoint → createApiClient → createApi
(what to call) (how to call it) (assembled client)| Step | Function | Role |
|---|---|---|
| 1 | defineEndpoint | Declares a URL, HTTP method, and TypeScript types for an endpoint |
| 2 | createApiClient | Creates an Axios instance with your base URL and default config |
| 3 | createApi | Combines your endpoint definitions and HTTP caller into a callable API object |
API Reference
defineEndpoint
Declares a typed endpoint. This function does not make any network calls — it only stores the URL and method and carries your type information.
Signature
function defineEndpoint<TResponse, TBody = never, TParams = never>(
url: string,
options?: { method?: Method },
): ApiEndpoint<TResponse, TBody, TParams>Type Parameters
| Parameter | Default | Description |
|---|---|---|
| TResponse | (required) | Shape of the successful response data |
| TBody | never | Shape of the request body (for POST/PUT/PATCH) |
| TParams | never | Shape of query/path parameters |
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| url | string | Yes | Endpoint path (e.g. /users, /users/:id) |
| options.method | Method | No | HTTP method — defaults to "GET" |
Examples
// GET with no body or params — response type only
const listPosts = defineEndpoint<Post[]>("/posts");
// POST with a typed request body
const createPost = defineEndpoint<Post, { title: string; body: string }>(
"/posts",
{ method: "POST" }
);
// GET with query/path parameters
const getPost = defineEndpoint<Post, never, { id: string }>("/posts/:id");
// PUT with both body and params
const updatePost = defineEndpoint<Post, { title: string }, { id: string }>(
"/posts/:id",
{ method: "PUT" }
);
// DELETE with params, void response
const deletePost = defineEndpoint<void, never, { id: string }>(
"/posts/:id",
{ method: "DELETE" }
);createApiClient
Creates an Axios-based HTTP caller function with default configuration.
Signature
function createApiClient(config: ApiClientConfig): HttpCallerApiClientConfig
| Option | Type | Default | Description |
|---|---|---|---|
| baseURL | string | (required) | Base URL prepended to all endpoint URLs |
| onUnauthorized | () => void | undefined | Called when any request receives a 401 response |
| withCredentials | boolean | true | Include cookies/credentials in cross-origin requests |
| timeout | number | 15000 | Request timeout in milliseconds |
| headers | object | { "Content-Type": "application/json" } | Default request headers |
| any | any | — | Any other AxiosRequestConfig option |
User-supplied values override the defaults above.
Examples
// Minimal setup
const httpCaller = createApiClient({
baseURL: "https://api.example.com/v1",
});
// With auth handling
const httpCaller = createApiClient({
baseURL: "https://api.example.com/v1",
onUnauthorized: () => {
localStorage.removeItem("token");
window.location.href = "/login";
},
});
// Custom timeout and headers
const httpCaller = createApiClient({
baseURL: "https://api.example.com/v1",
timeout: 30000,
headers: {
"Content-Type": "application/json",
"X-API-Version": "2",
},
});
// Disable credentials for public APIs
const httpCaller = createApiClient({
baseURL: "https://api.example.com/v1",
withCredentials: false,
});createApi
Assembles endpoint definitions and an HTTP caller into a fully-typed, callable API client object.
Signature
function createApi<TEndpoints extends EndpointGroups>(
endpoints: TEndpoints,
caller: HttpCaller,
): ApiFromEndpoints<TEndpoints>Parameters
| Parameter | Type | Description |
|---|---|---|
| endpoints | EndpointGroups | Nested object mapping group names to endpoint definitions |
| caller | HttpCaller | HTTP caller from createApiClient |
Endpoint groups structure
const endpoints = {
// group name
users: {
// endpoint name: endpoint definition
list: defineEndpoint<User[]>("/users"),
create: defineEndpoint<User, CreateUserInput>("/users", { method: "POST" }),
},
posts: {
getById: defineEndpoint<Post, never, { id: string }>("/posts/:id"),
},
};Calling endpoints
Each assembled endpoint is a function with this call signature:
api.group.endpoint(options?) => Promise<TResponse>The options object is typed based on the endpoint definition:
| Option | Type | When available |
|---|---|---|
| data | TBody | When TBody is defined (not never) |
| params | TParams | When TParams is defined (not never) |
| any | any | Any AxiosRequestConfig option except url |
// No options needed (GET with no params)
const users = await api.users.list();
// With request body
const user = await api.users.create({
data: { name: "Alice", email: "[email protected]" },
});
// With path/query params
const post = await api.posts.getById({ params: { id: "123" } });
// With extra Axios config (e.g. AbortController)
const controller = new AbortController();
const users = await api.users.list({ signal: controller.signal });ApiError
Custom error class thrown whenever a request fails. All Axios errors are normalized to ApiError before being re-thrown — you will never see a raw Axios error.
Class definition
class ApiError extends Error {
readonly name: "ApiError";
readonly status: number; // HTTP status code, or 500 for network/unexpected errors
readonly details?: unknown; // Raw response.data from the server
}Error normalization rules
| Scenario | status | message | details |
|---|---|---|---|
| HTTP error response | response.status | response.data.message or Axios error message | response.data |
| Network error / timeout | 500 | Axios error message | undefined |
| Non-Axios error | 500 | "Unexpected error" | The original error |
Usage
import { ApiError } from "@iamankan/react-api-client";
try {
const user = await api.users.getById({ params: { id: "not-found" } });
} catch (error) {
if (error instanceof ApiError) {
console.error(`[${error.status}] ${error.message}`);
// e.g. "[404] User not found"
if (error.status === 404) {
// Handle not found
} else if (error.status >= 500) {
// Handle server error
}
}
}TypeScript Types Reference
All types are exported from @iamankan/react-api-client and can be imported directly.
| Type | Description |
|---|---|
| ApiEndpoint<TResponse, TBody, TParams> | The return type of defineEndpoint |
| ApiClientConfig | Config object accepted by createApiClient |
| HttpCaller | The function type returned by createApiClient |
| EndpointGroups | Type constraint for the endpoints argument of createApi |
| ApiFromEndpoints<TEndpoints> | The type of the assembled API client returned by createApi |
| ApiCallOptions<TEndpoint> | Options accepted when calling an assembled endpoint method |
| EndpointResponse<TEndpoint> | Extracts TResponse from an ApiEndpoint type |
| EndpointBody<TEndpoint> | Extracts TBody from an ApiEndpoint type |
| EndpointParams<TEndpoint> | Extracts TParams from an ApiEndpoint type |
Using utility types to stay DRY
import type {
EndpointResponse,
EndpointBody,
EndpointParams,
} from "@iamankan/react-api-client";
const createUser = defineEndpoint<User, { name: string; email: string }>(
"/users",
{ method: "POST" }
);
// Derive types from the endpoint definition instead of repeating them
type CreateUserResponse = EndpointResponse<typeof createUser>; // User
type CreateUserBody = EndpointBody<typeof createUser>; // { name: string; email: string }Full Integration Example
A complete Users CRUD implementation showing real-world usage patterns.
src/api/endpoints/users.ts — define endpoints alongside their domain types
import { defineEndpoint } from "@iamankan/react-api-client";
export type User = {
id: string;
name: string;
email: string;
createdAt: string;
};
export type CreateUserInput = Pick<User, "name" | "email">;
export type UpdateUserInput = Partial<CreateUserInput>;
export const userEndpoints = {
list: defineEndpoint<User[]>("/users"),
getById: defineEndpoint<User, never, { id: string }>("/users/:id"),
create: defineEndpoint<User, CreateUserInput>("/users", { method: "POST" }),
update: defineEndpoint<User, UpdateUserInput, { id: string }>("/users/:id", { method: "PATCH" }),
remove: defineEndpoint<void, never, { id: string }>("/users/:id", { method: "DELETE" }),
};src/api/index.ts — assemble and export a single shared client
import { createApiClient, createApi } from "@iamankan/react-api-client";
import { userEndpoints } from "./endpoints/users";
const httpCaller = createApiClient({
baseURL: import.meta.env.VITE_API_BASE_URL,
onUnauthorized: () => {
window.location.href = "/login";
},
});
export const api = createApi({ users: userEndpoints }, httpCaller);src/components/UserList.tsx — use in a React component
import { useState, useEffect } from "react";
import { ApiError } from "@iamankan/react-api-client";
import { api } from "../api";
import type { User } from "../api/endpoints/users";
export function UserList() {
const [users, setUsers] = useState<User[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const controller = new AbortController();
api.users.list({ signal: controller.signal })
.then(setUsers)
.catch((err) => {
if (err instanceof ApiError) {
setError(`Failed to load users (${err.status}): ${err.message}`);
}
})
.finally(() => setLoading(false));
return () => controller.abort();
}, []);
if (loading) return <p>Loading...</p>;
if (error) return <p>{error}</p>;
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name} — {user.email}</li>
))}
</ul>
);
}src/components/CreateUser.tsx — mutation example
import { useState } from "react";
import { ApiError } from "@iamankan/react-api-client";
import { api } from "../api";
export function CreateUser() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
try {
const user = await api.users.create({ data: { name, email } });
console.log("Created:", user);
} catch (err) {
if (err instanceof ApiError) {
alert(`Error ${err.status}: ${err.message}`);
}
}
}
return (
<form onSubmit={handleSubmit}>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Name" />
<input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" />
<button type="submit">Create</button>
</form>
);
}Best Practices
1. Co-locate endpoint definitions with their domain
Keep endpoint definitions next to the types and components they serve. This makes it easy to update types and endpoints together when your API changes.
src/
api/
endpoints/
users.ts ← User type + userEndpoints
posts.ts ← Post type + postEndpoints
index.ts ← single exported `api` instance2. Export one shared api instance
Create a single api object and import it wherever you need it. Avoid creating multiple createApi or createApiClient calls — you'd create multiple Axios instances with separate interceptor chains.
// src/api/index.ts
export const api = createApi(endpoints, httpCaller);
// anywhere else
import { api } from "../api";3. Use utility types to avoid duplicating type definitions
Instead of re-declaring User or CreateUserInput in multiple places, derive them from the endpoint:
import type { EndpointBody, EndpointResponse } from "@iamankan/react-api-client";
import { userEndpoints } from "./endpoints/users";
type UserListResponse = EndpointResponse<typeof userEndpoints.list>; // User[]
type CreateUserBody = EndpointBody<typeof userEndpoints.create>; // CreateUserInput4. Centralize onUnauthorized for auth token management
The onUnauthorized callback fires on every 401 response. Use it to clear stored tokens and redirect to your login page:
const httpCaller = createApiClient({
baseURL: "https://api.example.com",
onUnauthorized: () => {
// Clear auth state
authStore.clearToken();
// Redirect
router.push("/login");
},
});5. Group endpoints by resource/domain
Structure endpoint groups to mirror your API's resource model. This keeps the assembled API intuitive to use:
const endpoints = {
auth: { login, logout, refresh },
users: { list, getById, create, update, remove },
posts: { list, getById, create, publish },
comments: { list, create, remove },
};
// Usage reads naturally
await api.auth.login({ data: credentials });
await api.posts.publish({ params: { id: "123" } });6. Cancel requests when components unmount
Pass an AbortController signal via options to avoid state updates on unmounted components:
useEffect(() => {
const controller = new AbortController();
api.users.list({ signal: controller.signal }).then(setUsers);
return () => controller.abort();
}, []);Error Handling Guide
Basic pattern
import { ApiError } from "@iamankan/react-api-client";
try {
const data = await api.users.list();
} catch (error) {
if (error instanceof ApiError) {
switch (error.status) {
case 400: console.error("Bad request:", error.details); break;
case 401: /* handled by onUnauthorized */ break;
case 403: console.error("Forbidden"); break;
case 404: console.error("Not found"); break;
default: console.error("Server error:", error.message);
}
}
}Async/await with error state in React
const [error, setError] = useState<ApiError | null>(null);
async function loadUser(id: string) {
try {
const user = await api.users.getById({ params: { id } });
setUser(user);
} catch (err) {
if (err instanceof ApiError) setError(err);
}
}
// In JSX
{error && <ErrorBanner status={error.status} message={error.message} />}Reading server-provided error details
When your API returns a structured error body, access it via error.details:
// Server responds with: { message: "Validation failed", fields: { email: "Invalid format" } }
catch (error) {
if (error instanceof ApiError && error.status === 422) {
const fields = (error.details as any)?.fields;
// { email: "Invalid format" }
}
}Configuration Reference
Full list of options accepted by createApiClient:
| Option | Type | Default | Description |
|---|---|---|---|
| baseURL | string | (required) | Prepended to all endpoint URLs |
| onUnauthorized | () => void | undefined | Fired on 401 responses before the error propagates |
| withCredentials | boolean | true | Send cookies on cross-origin requests |
| timeout | number | 15000 | Milliseconds before the request times out |
| headers | RawAxiosHeaders | { "Content-Type": "application/json" } | Default headers on every request |
| responseType | string | "json" | Expected response data type |
| maxRedirects | number | 5 | Maximum number of redirects |
All other Axios request config options are also accepted and forwarded directly to the underlying Axios instance.
Build & Distribution
The package ships dual-format bundles for broad compatibility:
| Format | File | Use case |
|---|---|---|
| ES Modules | dist/index.js | Bundlers (Vite, webpack, Rollup) |
| CommonJS | dist/index.cjs | Node.js require() |
| TypeScript declarations | dist/index.d.ts / dist/index.d.cts | IDE and type checking |
Source maps are included for all formats.
License
MIT
