@iremlopsum/apify
v1.0.0
Published
Runtime-agnostic, type-safe API client built on standard fetch. Zero dependencies.
Maintainers
Readme
apify
Runtime-agnostic, type-safe API client built on standard fetch. Zero dependencies.
npm install @iremlopsum/apifyGetting Started
Define your endpoints as Request instances, wire them into a client with createApi, and call them with full type safety.
import { createApi, Request } from '@iremlopsum/apify'
// 1. Define your endpoints
interface User {
id: string
name: string
email: string
}
const getUser = new Request<{ id: string }, User>({
method: 'GET',
path: '/users/:id'
})
const createUser = new Request<{ name: string; email: string }, User>({
method: 'POST',
path: '/users'
})
// 2. Create the client
const api = createApi({
baseUrl: 'https://api.example.com',
requests: { getUser, createUser },
onError: (error) => console.error(`${error.request.method} ${error.request.url}`, error.status)
})
// 3. Make a call — params and response are fully typed
const { data, error, retry } = await api.getUser({ id: '42' })
if (error) {
console.error(error.status, error.body)
return
}
// data is typed as User
console.log(data.name)Deep Dive
Request
Each API endpoint is represented by a Request instance. The class is a typed config container -- it stores the recipe for how an endpoint should be called, but does not execute anything on its own.
import { Request } from '@iremlopsum/apify'
const listItems = new Request<{ page: number; limit: number }, Item[]>({
method: 'GET',
path: '/items'
})The two type parameters drive the entire type system:
TParams-- the shape of the params object the caller must provide (path params, query params, and body params combined).TResponse-- the shape of the successful response data. This becomes the type ofresult.data.
When an endpoint takes no params, use Record<string, never> and the generated method will accept an optional (or omitted) params argument:
const health = new Request<Record<string, never>, { status: string }>({
method: 'GET',
path: '/health'
})
// Both work:
await api.health()
await api.health({})Path parameters
Use :param syntax in the path. Matching keys from the params object are substituted into the URL and excluded from the query string or body:
const getItem = new Request<{ orgId: string; id: string }, Item>({
method: 'GET',
path: '/orgs/:orgId/items/:id'
})
// Calls GET /orgs/acme/items/42
await api.getItem({ orgId: 'acme', id: '42' })responseType
Controls how the response body is parsed. Defaults to 'json'.
const downloadFile = new Request<{ id: string }, Blob>({
method: 'GET',
path: '/files/:id',
responseType: 'blob'
})See Response parsing for all options.
dedupe
When true, firing a new call to this endpoint auto-cancels any previous in-flight call. Useful for search-as-you-type or rapidly changing filters:
const searchUsers = new Request<{ q: string }, User[]>({
method: 'GET',
path: '/users/search',
dedupe: true
})
// If a second call starts before the first finishes, the first is aborted
await api.searchUsers({ q: 'hel' })
await api.searchUsers({ q: 'hello' }) // previous call is auto-cancelledbodyAs
Overrides the default body serialization strategy. By default, GET/DELETE serialize params as query strings and POST/PUT/PATCH serialize params as a JSON body. Use bodyAs to invert that:
// DELETE endpoint that expects a JSON body
const bulkDelete = new Request<{ ids: string[] }, { deleted: number }>({
method: 'DELETE',
path: '/items',
bodyAs: 'body'
})
// POST endpoint that sends params as query string
const triggerJob = new Request<{ priority: number }, Job>({
method: 'POST',
path: '/jobs/trigger',
bodyAs: 'query'
})Query strings
For GET and DELETE requests (or any request with bodyAs: 'query'), params that are not consumed by path substitution are serialized as a query string using URLSearchParams.
| Input | Output |
| ------------------------------ | --------------------------- |
| { page: 1, limit: 20 } | ?page=1&limit=20 |
| { tags: ['a', 'b'] } | ?tags=a&tags=b |
| { filter: null } | (omitted) |
| { filter: undefined } | (omitted) |
| { meta: { nested: true } } | TypeError (see below) |
Arrays use repeated keys (tags=a&tags=b), which is the most widely supported format across server frameworks.
null and undefined values are silently omitted from the query string.
Nested objects throw a TypeError with a descriptive message. Flatten the structure before passing. This is intentional -- there is no universal standard for serializing nested objects in query strings (brackets, dots, JSON), so the library refuses to guess.
Result
Every API call returns a Result<TResponse> instead of throwing. The shape is always the same:
interface Result<TResponse> {
data: TResponse | null // parsed response on success, null on error
error: ApiError | null // structured error on failure, null on success
response: Response | null // raw fetch Response (null for network errors)
retry: () => Promise<Result<TResponse>>
}Check error first, then use data with confidence:
const { data, error, response, retry } = await api.getUser({ id: '42' })
if (error) {
if (error.status === 0) {
// Network error -- user is probably offline
} else if (error.status === 401) {
redirectToLogin()
} else {
console.error(error.status, error.body)
}
return
}
// data is typed as User, error is null
console.log(data.name)retry()
The retry function re-executes the exact same request through the full middleware chain. Auth tokens are re-injected, logging fires again, everything runs fresh. This is useful for retry-after-refresh patterns:
const { data, error, retry } = await api.getUser({ id: '42' })
if (error?.status === 401) {
await refreshToken()
const retried = await retry()
// retried goes through the full middleware chain again
}ApiError
The error object on failed calls. It is not a subclass of Error -- it is a structured container for API-level error details.
| Property | Type | Description |
| ------------ | --------- | ----------------------------------------------------------------- |
| status | number | HTTP status code (e.g., 404, 500). 0 for network errors/aborts. |
| statusText | string | HTTP status text (e.g., 'Not Found'). '' for network errors. |
| body | unknown | Parsed response body, or the native Error for network failures. |
| headers | Headers | Response headers. Empty Headers for network errors. |
| request | object | { method, url, params } -- metadata about the failed request. |
You can use instanceof to check if a value is an ApiError:
import { ApiError } from '@iremlopsum/apify'
if (error instanceof ApiError) {
// ...
}Error handling with onError
The onError callback in createApi fires after the full middleware chain completes whenever the final result has an error. If a retry middleware recovers a 5xx to a 200, onError does not fire.
const api = createApi({
baseUrl: '/api',
requests: { getUser, createUser },
onError: (error) => {
if (error.status === 401) redirectToLogin()
Sentry.captureException(error)
}
})This fires for both HTTP errors (4xx, 5xx) and network errors (status 0). It is a global hook for side effects (logging, telemetry, redirects) -- it does not change the result returned to the caller.
Middleware
Middleware follows the onion model (like Koa or Redux middleware). Each middleware wraps the next layer, can modify the request going in and the result coming out.
Request → [Global MW → [Per-request MW → [Per-call MW → [fetch]]]]A middleware function receives a context and a next function:
import type { Middleware } from '@iremlopsum/apify'
const authMiddleware: Middleware = async (ctx, next) => {
// Before: modify the request
ctx.request.headers.set('Authorization', `Bearer ${getToken()}`)
// Call the next layer
const result = await next()
// After: inspect or transform the result
return result
}What middleware can do
- Modify the request -- set headers, change the body, rewrite the URL.
- Short-circuit -- return early without calling
next()(e.g., serve from cache). - Retry -- call
next()multiple times in a loop (e.g., retry on 5xx). - Inspect the result -- log, report errors, transform response data.
Three layers
Middleware is applied at three levels. The execution order is global first, per-request second, per-call third:
// Global -- applies to every endpoint
const api = createApi({
baseUrl: '/api',
requests: { getUser, createUser },
middleware: [authMiddleware, logMiddleware]
})
// Per-request -- applies only to this endpoint
const getUser = new Request<{ id: string }, User>({
method: 'GET',
path: '/users/:id',
middleware: [cacheMiddleware]
})
// Per-call -- applies only to this single invocation
await api.getUser({ id: '42' }, {
middleware: [customTraceMiddleware]
})skipMiddleware
Remove specific middleware for a single call by passing references to skipMiddleware:
const retry = retryMiddleware(3)
const api = createApi({
baseUrl: '/api',
requests: { getUser },
middleware: [retry, logMiddleware]
})
// Skip retry for this one call
await api.getUser({ id: '42' }, {
skipMiddleware: [retry]
})Comparison is by reference (===). Factory-style middleware like retryMiddleware(3) must be stored in a variable first -- calling the factory again creates a new reference that will not match.
MiddlewareContext
The context object passed to each middleware:
| Property | Type | Description |
| --------------------- | --------- | -------------------------------------------------------- |
| request.method | string | HTTP method (GET, POST, etc.) |
| request.url | string | Fully resolved URL with path params and query string |
| request.path | string | Original path template (e.g., '/users/:id') |
| request.params | unknown | Original params object from the caller |
| request.headers | Headers | Merged headers -- middleware can add/remove entries |
| request.body | unknown | Serialized body, or null for GET/DELETE |
| requestName | string | Key name in the requests object (e.g., 'getUser') |
Writing custom middleware
A cache middleware that short-circuits on cache hits:
const cacheMiddleware: Middleware = async (ctx, next) => {
const cached = cache.get(ctx.request.url)
if (cached) return cached
const result = await next()
if (result.data) {
cache.set(ctx.request.url, result)
}
return result
}An error reporting middleware:
const sentryMiddleware: Middleware = async (ctx, next) => {
const result = await next()
if (result.error && result.error.status >= 500) {
Sentry.captureMessage(`API error: ${ctx.request.method} ${ctx.request.url}`, {
extra: { status: result.error.status, body: result.error.body }
})
}
return result
}Built-in middleware
The library ships two optional middleware functions, importable from a separate entry point:
import { retryMiddleware, logMiddleware } from '@iremlopsum/apify/middleware'retryMiddleware(maxRetries?: number)
Automatically retries requests that return a 5xx server error. The maxRetries parameter (default: 3) is the number of additional attempts after the initial one, so retryMiddleware(2) means up to 3 total attempts.
Only retries server errors (status >= 500). Client errors (4xx) and network errors (status 0) are not retried.
const api = createApi({
baseUrl: '/api',
requests: { getItems },
middleware: [retryMiddleware(2)]
})logMiddleware
Logs request start and completion to the console with timing:
[apify] → GET getItems /api/items
[apify] ← getItems OK (142ms)
[apify] → POST createUser /api/users
[apify] ← createUser ERROR 422 (89ms)Intended for development. In production, write a custom middleware that sends telemetry to your observability platform.
const api = createApi({
baseUrl: '/api',
requests: { getItems },
middleware: [logMiddleware]
})Content types
Request bodies are automatically serialized based on the input type. The Content-Type header is set for you unless you explicitly provide one.
| Input type | Body output | Content-Type |
| ----------------- | ------------------ | ------------------------------------- |
| null/undefined | null | (none) |
| string | as-is | text/plain |
| FormData | as-is | (browser sets multipart boundary) |
| URLSearchParams | as-is | application/x-www-form-urlencoded |
| Blob | as-is | application/octet-stream |
| ArrayBuffer | as-is | application/octet-stream |
| Plain object | JSON.stringify() | application/json |
Header merge precedence (most specific wins):
- Global headers (from
createApiconfig) -- lowest priority - Per-request headers (from
Requestconfig) -- overrides global - Per-call headers (from
CallOptions) -- highest priority
Explicitly set Content-Type headers at any level override the auto-detected value.
Response parsing
The responseType option on a Request determines how the response body is parsed:
| responseType | Method called | Return type |
| --------------- | ---------------------- | -------------- |
| 'json' | response.text() then JSON.parse() | parsed object |
| 'text' | response.text() | string |
| 'blob' | response.blob() | Blob |
| 'arrayBuffer' | response.arrayBuffer() | ArrayBuffer |
| 'formData' | response.formData() | FormData |
The default is 'json'. JSON parsing reads the body as text first and then parses, so empty responses (e.g., 204 No Content) return null instead of throwing a parse error.
Cancellation
Manual abort via AbortSignal
Pass an AbortSignal through CallOptions to cancel a request:
const controller = new AbortController()
const promise = api.getItems({ page: 1 }, {
signal: controller.signal
})
// Cancel the request
controller.abort()
const { error } = await promise
// error.status === 0, error.body is a DOMException with name 'AbortError'Auto-cancel via dedupe
When a Request has dedupe: true, each new call automatically aborts the previous in-flight call for that endpoint. Identity is per Request instance -- different endpoints do not interfere with each other.
const searchUsers = new Request<{ q: string }, User[]>({
method: 'GET',
path: '/users/search',
dedupe: true
})
const api = createApi({
baseUrl: '/api',
requests: { searchUsers }
})
// Rapid calls -- only the last one completes
api.searchUsers({ q: 'h' }) // aborted by next call
api.searchUsers({ q: 'he' }) // aborted by next call
api.searchUsers({ q: 'hel' }) // this one completesDedupe and manual abort signals work together. If both are active, the request is cancelled if either fires.
TypeScript
Type inference flows automatically from Request generics through createApi to the call site. You never annotate the API methods manually.
// 1. Types are declared on the Request
const getUser = new Request<{ id: string }, User>({
method: 'GET',
path: '/users/:id'
})
// 2. createApi infers method signatures from the requests record
const api = createApi({
baseUrl: '/api',
requests: { getUser }
})
// 3. Call site is fully typed -- no annotations needed
const { data, error } = await api.getUser({ id: '42' })
// ^? User | nullThe inference chain works like this:
Request<TParams, TResponse>carries the type info.createApiuses internal conditional types to pull theTParamsandTResponsegenerics from eachRequestinstance.- A mapped type transforms the requests record into callable methods: each key becomes
(params: TParams, options?: CallOptions) => Promise<Result<TResponse>>. - When
TParamsisRecord<string, never>(no params), the params argument becomes optional.
All exported types are available for annotation when needed:
import type {
Result,
CallOptions,
Middleware,
MiddlewareContext,
MiddlewareNext,
RequestConfig,
ApiConfig
} from '@iremlopsum/apify'Philosophy
Never throws
Every API call returns a Result<T>. HTTP errors, network failures, parse errors, and even synchronous exceptions during request setup are all captured and returned as structured { data, error, response, retry } objects. No try/catch required at call sites.
Zero dependencies
The library uses only the standard fetch API and built-in web platform types (Headers, AbortController, FormData, URLSearchParams, Blob, ArrayBuffer). There is nothing to install, audit, or bundle beyond the library itself.
Middleware over interceptors
Instead of separate onRequest/onResponse interceptor hooks, the library uses a composable onion model where each middleware wraps the next. This means a single function can modify the request, inspect the response, retry on failure, or short-circuit entirely. Three layers (global, per-request, per-call) plus skipMiddleware give fine-grained control without configuration complexity.
Typed dot-access
Type safety comes from inference, not annotation. Define Request<TParams, TResponse> once, and createApi infers everything downstream. The call site (api.getUser(...)) is fully typed with zero extra work.
Runtime-agnostic
No assumptions about Node.js, browsers, or any specific runtime. If your environment has fetch, the library works -- browsers, Node.js 18+, Bun, Deno, React Native, Cloudflare Workers, edge runtimes.
API Reference
Core (@iremlopsum/apify)
| Export | Kind | Description |
| --------------- | -------- | ------------------------------------------------------------------ |
| createApi | function | Creates a typed API client from a config of Request definitions |
| Request | class | Typed endpoint definition -- one instance per endpoint |
| ApiError | class | Structured error with status, body, headers, and request metadata |
| RequestConfig | type | Config object for the Request constructor |
| ApiConfig | type | Config object for createApi |
| CallOptions | type | Per-call overrides (middleware, headers, signal) |
| Result | type | Return shape of every API call: { data, error, response, retry } |
| Middleware | type | Middleware function signature: (ctx, next) => Promise<Result> |
| MiddlewareContext | type | Request context passed to middleware |
| MiddlewareNext | type | The next function passed to middleware |
Built-in middleware (@iremlopsum/apify/middleware)
| Export | Kind | Description |
| ----------------- | -------- | ------------------------------------------------------------- |
| retryMiddleware | function | Factory that returns middleware to retry on 5xx server errors |
| logMiddleware | const | Middleware that logs request lifecycle to the console |
License
MIT
