Beyond Axios: From Imperative Requests to Declarative Transport Policies with pureq
Most TypeScript teams still start with one default answer for HTTP: "just use Axios".
That was a good answer for years.
But today, the core challenge is no longer sending requests. It is designing transport behavior that stays reliable, observable, and maintainable as systems grow.
That is a paradigm shift:
- Imperative style (per-call patches and interceptor side effects)
- Declarative policy style (explicit, composable transport rules)
pureq is built for the second model.
Why Existing Options Start to Hurt
Native fetch: flexible, but incomplete by default
fetch gives you primitives, not a reliability system. Teams usually rebuild:
- Timeout/deadline behavior
- Retry rules
- Circuit breaker logic
- Deduplication
- Unified error classification
This often leads to duplicated utilities and inconsistent behavior across services.
Axios: ergonomic, but increasingly implicit at scale
Axios interceptors are useful, but large codebases tend to hit:
- Hidden side effects
- Order-dependent behavior that is hard to audit
- Blurred boundaries between clients (public/auth/admin/internal)
fetch vs Axios vs pureq (At a Glance)
| Capability | fetch | Axios | pureq |
|---|---|---|---|
| Immutable client composition | No | No (instance config is mutable) | Yes (use() returns a new client) |
| Resilience policies (retry/circuit/deadline/dedupe) | Manual | Partial/custom interceptor logic | First-class middleware |
| Middleware ordering model | Manual wrappers | Interceptor chains | Explicit onion model |
| Result pattern (non-throwing API) | Manual | Mostly exception-first | Built-in *Result APIs |
| Observability hooks / OTel mapping | Manual | Manual | Built-in diagnostics + OTel mapping |
| Runtime dependencies | N/A (platform API) | External package | Zero runtime dependencies |
What pureq Is
pureq is a policy-first HTTP transport layer for TypeScript.
Core ideas:
- Policy-first design
- Immutable clients
- Composable middleware stack
- Result-oriented error handling
Also important in practice:
- Zero runtime dependencies (Lightweight core, no supply chain bloat)
- Cross-runtime (Browser, Node.js, Bun, Deno, and Edge)
Quick Start
npm install @pureq/pureq
import { createClient } from "@pureq/pureq";
const api = createClient({
baseURL: "https://api.example.com",
headers: {
"Content-Type": "application/json",
},
});
Design Highlights
1. Immutable composition
use() does not mutate the existing client.
import { createClient, retry, authRefresh, dedupe } from "@pureq/pureq";
const base = createClient({ baseURL: "https://api.example.com" })
.use(retry({ maxRetries: 2, delay: 300 }));
const privateApi = base.use(
authRefresh({
status: 401,
refresh: async () => getNewToken(),
})
);
const publicApi = base.use(dedupe());
This makes policy branching explicit and safe.
2. Explicit middleware order (Onion model)
import { createClient, dedupe, retry, circuitBreaker } from "@pureq/pureq";
const resilientApi = createClient({ baseURL: "https://api.example.com" })
.use(dedupe())
.use(
retry({
maxRetries: 3,
delay: 200,
retryOnStatus: [429, 500, 503],
})
)
.use(
circuitBreaker({
failureThreshold: 5,
cooldownMs: 30_000,
})
);
Built-in Capabilities
- retry
- circuit breaker
- dedupe
- timeout / deadline
- auth refresh
- hedged requests
- concurrency limits
- HTTP cache
- offline queue
- validation / fallback
- diagnostics and OpenTelemetry mapping
Validation Example (Zod/Valibot Friendly)
pureq ships a zero-dependency validation middleware that can bridge external schema libraries.
import { createClient, validation } from "@pureq/pureq";
import { z } from "zod";
const UserSchema = z.object({
id: z.string(),
name: z.string(),
});
const api = createClient({ baseURL: "https://api.example.com" }).use(
validation({
validate: (data) => UserSchema.parse(data),
message: "Response validation failed",
})
);
The same shape works with Valibot validators as well.
Result Pattern: Errors as Values, Not Exceptions
pureq separates transport failures and HTTP failures via typed Result unions.
const result = await api.getJsonResult<User>("/users/:id", {
params: { id: "42" },
});
if (!result.ok) {
switch (result.error.kind) {
case "timeout":
showToast("Request timed out");
break;
case "circuit-open":
showFallbackUI();
break;
case "http":
if (result.error.status === 401) {
logout();
}
break;
default:
reportError(result.error);
}
return;
}
// TypeScript narrows here: result is { ok: true; data: User }
renderUser(result.data);
Why this matters:
- Better exhaustiveness and discoverability in code review
- Fewer hidden throw paths in async call chains
- Stronger type safety for success/failure handling
Works Well with React Query / SWR
Use pureq for transport policy, then layer state tools on top.
import { useQuery } from "@tanstack/react-query";
function useUser(id: string) {
return useQuery({
queryKey: ["user", id],
queryFn: async () => {
const result = await api.getJsonResult<User>("/users/:id", {
params: { id },
});
if (!result.ok) {
throw result.error;
}
return result.data;
},
});
}
Where pureq Fits Best
- Large frontends with shared transport policy
- BFF/backends with reliability and observability requirements
- Multi-runtime deployments including edge environments
- Teams that need predictable, auditable transport behavior
Where It May Be Overkill
- Very small apps with minimal HTTP complexity
- Short-lived prototypes
- Cases where speed of initial setup matters more than long-term policy consistency
Final Note
pureq is not trying to be "yet another HTTP helper".
It is a transport design model: explicit policies, immutable composition, and typed failure handling.
I actively dogfood pureq in production workloads and keep evolving it based on real incidents and maintenance pressure.
If this aligns with your architecture goals:
- Star the repo: https://github.com/shiro-shihi/pureq
- Try the package: https://www.npmjs.com/package/@pureq/pureq
- Open an issue with feedback or edge cases you want covered
Top comments (0)