api
@mrzr/api-client
Reference

TypeScript Types

Complete TypeScript type definitions and generics reference

The complete public type surface. Everything here is importable.

import type { IRes, ApiClient, ClientOptions /* … */ } from "@mrzr/api-client";

Primitives

type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
type AuthMode = "header" | "cookie";
type StorageKind = "memory" | "local" | "session" | "cookie";

IRes<R> — the response envelope

interface IRes<R = unknown> {
  statusCode: number;
  status: boolean;
  message: string;
  data?: R;
  loading: boolean;
  errors?: Record<string, string[]>;
  error?: unknown;
  headers?: Record<string, string>;
  canceled?: boolean;
  cancelReason?: string;
}

data is optional because a 204, an empty body, or a parse failure legitimately yields no payload. Narrow it with status:

const res = await api.get<User[]>("/users", { throwError: false });
if (res.status) {
  res.data!.map(u => u.name);   // non-null assertion is safe here
}

Or a type guard that makes data required:

function ok<R>(res: IRes<R>): res is IRes<R> & { data: R } {
  return res.status && res.data !== undefined;
}

if (ok(res)) res.data.map(u => u.name); // no assertion needed

ApiError

class ApiError extends Error {
  readonly name: "ApiError";
  readonly statusCode: number;
  readonly errors?: Record<string, string[]>;
  readonly data?: unknown;
  readonly response: IRes<unknown>;
  readonly canceled: boolean;
  readonly cancelReason?: string;
}

Typing the field-error map for a specific form:

interface SignupErrors {
  email?: string[];
  password?: string[];
}

function signupErrors(e: unknown): SignupErrors {
  return e instanceof ApiError ? ((e.errors ?? {}) as SignupErrors) : {};
}

RequestConfig<T>

interface RequestConfig<T = unknown>
  extends Omit<RequestInit, "body" | "method" | "headers" | "credentials"> {
  headers?: Record<string, string>;
  addToUrl?: (string | number)[];
  addTemplateToUrl?: Record<string, string | number>;
  params?: Params<T>;
  baseUrl?: string;
  timeout?: number;
  fullData?: boolean;
  refreshTokenCheck?: boolean;
  skipAuth?: boolean;
  stringifyBody?: boolean;
  isFormData?: boolean;
  hideErrorMessage?: boolean;
  throwError?: boolean;
  uploadSkewMs?: number;
  duplex?: "half";
  log?: boolean;
  cancelable?: boolean;
  cancelKey?: string;
  cancelGroup?: string | string[];
  takeLatest?: boolean;
  throwOnCancel?: boolean;
  beforeFunc?: (body: unknown) => unknown;
  afterFunc?: (data: T) => unknown;
  beforeSelectOptions?: (data: T) => unknown;
}

Extending RequestInit is what makes cache, mode, redirect, signal, keepalive and friends available with correct types.

The T parameter types the transform hooks and params.ordering. Usually it is inferred from the method's R:

await api.get<User[]>("/users", {
  afterFunc: (data) => data.filter(u => u.active), // data: User[]
});

Cancellation types

interface CancelOptions {
  methods?: HttpMethod[] | "all";   // default ["GET"]
  throwOnCancel?: boolean;          // default false; independent of throwError
  takeLatest?: boolean;             // default false
}

interface PendingRequest {
  id: number;
  method: HttpMethod;
  url: string;      // fully resolved, query included
  path: string;     // no origin, no query — what patterns match
  key?: string;
  groups: string[];
  startedAt: number;
}

interface CancelMatch {
  url?: string | RegExp;
  method?: HttpMethod | HttpMethod[];
  key?: string;
  group?: string;
}

type CancelSelector =
  | string
  | RegExp
  | CancelMatch
  | ((request: PendingRequest) => boolean);

interface CancelScope {
  readonly name: string;
  get<R = unknown>(url: string, config?: RequestConfig<R>): Promise<IRes<R>>;
  post<R = unknown>(url: string, body?: unknown, config?: RequestConfig<R>): Promise<IRes<R>>;
  put<R = unknown>(url: string, body?: unknown, config?: RequestConfig<R>): Promise<IRes<R>>;
  patch<R = unknown>(url: string, body?: unknown, config?: RequestConfig<R>): Promise<IRes<R>>;
  delete<R = unknown>(url: string, config?: RequestConfig<R>): Promise<IRes<R>>;
  cancel(reason?: string): number;
  pending(): PendingRequest[];
}

A predicate selector gets full type inference:

api.cancel((r) => r.method === "GET" && r.startedAt < Date.now() - 5_000);

See [[Cancellation]].


Params<T> and Ordering<T>

type Ordering<T = unknown> = { [K in keyof T]?: "asc" | "desc" };

interface Params<T = unknown> {
  ordering?: T extends ListResponse<infer R> ? Ordering<R> : Ordering<T>;
  [key: string]: unknown;
}

ordering unwraps ListResponse<T> automatically, so you order by the item fields:

await api.get<ListResponse<User>>("/users", {
  params: { ordering: { createdAt: "desc" } }, // keys are User's, not ListResponse's
});

The index signature keeps arbitrary params allowed.


ListResponse<T>

interface ListResponse<T = unknown> {
  count: number;
  next: string | null;
  previous: string | null;
  results: T[];
}

The DRF-style pagination shape, provided for convenience:

const { data } = await api.get<ListResponse<User>>("/users", { params: { page: 2 } });
data?.results;  // User[]
data?.count;    // number

Auth types

interface AuthState {
  isAuthenticated: boolean;
  expiresAt: number | null;
  user?: unknown;
}

interface TokenPair {
  accessToken?: string;
  refreshToken?: string;
  expiresAt?: number;   // epoch ms; derived from the JWT `exp` when omitted
}

type TokenExtractor = (body: unknown) => TokenPair | null;

user is unknown because it is whatever your server returns. Narrow it in your own layer:

interface AppUser { id: number; email: string; role: "admin" | "member" }

export const currentUser = (s: AuthState) => s.user as AppUser | undefined;

Or wrap the whole state:

type TypedAuthState = Omit<AuthState, "user"> & { user?: AppUser };

api.onAuthStateChange((s) => setState(s as TypedAuthState));

TokenStorage

interface TokenStorage {
  get(): Promise<TokenPair | null> | TokenPair | null;
  set(tokens: TokenPair): Promise<void> | void;
  clear(): Promise<void> | void;
}

Sync or async — the client awaits everything. See [[Storage Adapters]].


LogEntry

interface LogEntry {
  url: string;
  method: HttpMethod;
  statusCode: number;
  status: boolean;
  message: string;
  durationMs: number;
  timestamp: string;
  error?: unknown;
}

ClientOptions

interface ClientOptions {
  baseUrl?: string;
  timeout?: number;
  throwError?: boolean;

  xsrfCookieName?: string;
  xsrfHeaderName?: string;
  getCsrfToken?: () => string | undefined;

  headers?: Record<string, string>;
  authMode?: AuthMode;
  credentials?: RequestCredentials;

  cancel?: boolean | CancelOptions;

  worker?: boolean;
  multiTab?: boolean;

  storage?: StorageKind | TokenStorage;
  storageKey?: string;

  loginUrl?: string;
  refreshUrl?: string;
  logoutUrl?: string;
  refreshSkewMs?: number;

  extractTokens?: TokenExtractor;
  buildRefreshBody?: (refreshToken?: string) => unknown;

  onAuthStateChanged?: (state: AuthState) => void;
  onAuthFailure?: () => void;
  onError?: (error: IRes<unknown>) => void;
  onLog?: (entry: LogEntry) => void;
}

See [[Client Options]] for defaults and behaviour.


ApiClient

interface ApiClient {
  get<R = unknown>(url: string, config?: RequestConfig<R>): Promise<IRes<R>>;
  post<R = unknown>(url: string, body?: unknown, config?: RequestConfig<R>): Promise<IRes<R>>;
  put<R = unknown>(url: string, body?: unknown, config?: RequestConfig<R>): Promise<IRes<R>>;
  patch<R = unknown>(url: string, body?: unknown, config?: RequestConfig<R>): Promise<IRes<R>>;
  delete<R = unknown>(url: string, config?: RequestConfig<R>): Promise<IRes<R>>;

  login<R = unknown>(body: unknown, config?: RequestConfig<R>): Promise<IRes<R>>;
  logout<R = unknown>(config?: RequestConfig<R>): Promise<IRes<R>>;
  setTokens(tokens: TokenPair): Promise<void>;
  refresh(): Promise<string | null>;
  getAuthState(): Promise<AuthState>;
  onAuthStateChange(listener: (state: AuthState) => void): () => void;

  cancel(selector?: CancelSelector, reason?: string): number;
  pending(selector?: CancelSelector): PendingRequest[];
  cancelScope(name?: string): CancelScope;

  readonly isWorker: boolean;
  destroy(): void;
}

Patterns

A typed API layer

// api/users.ts
import { api } from "@/lib/api";
import type { ListResponse } from "@mrzr/api-client";

export interface User { id: number; name: string; email: string }
export interface CreateUser { name: string; email: string }

export const users = {
  list: (page = 1) =>
    api.get<ListResponse<User>>("/users", { params: { page } }).then(r => r.data!),

  byId: (id: number) =>
    api.get<User>("/users/{id}", { addTemplateToUrl: { id } }).then(r => r.data!),

  create: (input: CreateUser) =>
    api.post<User>("/users", input).then(r => r.data!),

  update: (id: number, patch: Partial<CreateUser>) =>
    api.patch<User>("/users/{id}", patch, { addTemplateToUrl: { id } }).then(r => r.data!),

  remove: (id: number) =>
    api.delete<void>("/users/{id}", { addTemplateToUrl: { id } }),
};

.then(r => r.data!) is safe here because throwError is on: a failure rejects before the then.

Deriving types from a route map

interface Routes {
  "/users": { GET: User[]; POST: User };
  "/users/{id}": { GET: User; PATCH: User; DELETE: void };
}

function typedGet<P extends keyof Routes>(
  path: P,
  config?: RequestConfig<Routes[P] extends { GET: infer R } ? R : never>,
) {
  return api.get<Routes[P] extends { GET: infer R } ? R : never>(path as string, config);
}

const users = await typedGet("/users"); // IRes<User[]>

Narrowing an unknown error

function isApiError(e: unknown): e is ApiError {
  return e instanceof ApiError;
}

function isStatus(e: unknown, code: number): e is ApiError {
  return isApiError(e) && e.statusCode === code;
}

if (isStatus(err, 409)) showConflictDialog(err.message);

Next: [[Framework Recipes]]

On this page