Learn to build type-safe, error-handling HTTP clients with trembita — from basics to advanced patterns.
- Core Concepts
- Getting Started
- The Result Pattern
- Building a Client
- Error Handling
- Advanced Features
- Testing
- Common Patterns
Trembita enforces three principles:
Instead of new Client(), you call factory functions:
const result = createTrembita({ endpoint: '...' });Every operation returns either { ok: true, value } or { ok: false, error }.
No try/catch for normal HTTP outcomes:
const response = await client.request({ path: '/' });
if (!response.ok) {
// handle response.error.kind
}Uses only fetch and URL from Node/browser. Inject your own fetchImpl for
testing—no global mocking.
npm install trembitaimport { createTrembita, HTTP_OK } from 'trembita';
// Create a client bound to a base URL
const api = createTrembita({ endpoint: 'https://api.github.com' });
if (!api.ok) throw api.error; // Init error
// Make a request
const repos = await api.value.request({
path: '/users/octocat/repos',
expectedCodes: [HTTP_OK]
});
// Handle the result
if (repos.ok) {
console.log(repos.value); // parsed JSON
} else {
console.error(repos.error.kind); // e.g. 'fetch_failed'
}3 key steps:
createTrembita()→ validates options, returnsResult.request()→Promise<Result<unknown, TrembitaRequestError>>- Check
.okto narrow the type
A simple discriminated union that forces you to handle both success and failure:
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };Why? TypeScript narrows the type when you check .ok:
const r = await client.request({ path: '/' });
if (r.ok) {
// TypeScript knows r.value exists
console.log(r.value.length);
} else {
// TypeScript knows r.error exists
if (r.error.kind === 'fetch_failed') {
console.error('Network error:', r.error.cause);
}
}// Option 1: if/else
if (result.ok) {
doSomething(result.value);
} else {
console.error(result.error.kind);
}
// Option 2: switch on error.kind
switch (result.error.kind) {
case 'unexpected_status':
console.error('HTTP', result.error.statusCode);
break;
case 'fetch_failed':
console.error('Network:', result.error.cause);
break;
// TypeScript requires all cases or a default
}
// Option 3: throw if you must
if (!result.ok) throw new Error(result.error.kind);
const data = result.value;| kind | Meaning | Example |
|---|---|---|
missing_endpoint |
No endpoint in options | createTrembita({}) |
endpoint_invalid_url |
Endpoint URL is malformed | endpoint: 'not a url' |
invalid_request_options |
Missing path or invalid query | request({ query: 123 }) |
fetch_failed |
Network error (DNS, timeout) | Connection reset |
timeout |
Request exceeded timeoutMs |
Exceeded 30s |
invalid_json |
Response body isn't JSON | Response is HTML |
unexpected_status |
Status not in expectedCodes |
Got 404, expected 200 |
import { createTrembita, HTTP_OK, HTTP_CREATED } from 'trembita';
const githubApi = createTrembita({
endpoint: 'https://api.github.com'
});
if (!githubApi.ok) {
throw new Error(`Bad endpoint: ${githubApi.error.kind}`);
}
const client = githubApi.value; // now safe to use// Encapsulate common patterns
async function getUser(username: string) {
return client.request({
path: `/users/${username}`,
expectedCodes: [HTTP_OK]
});
}
async function createIssue(repo: string, title: string, body: string) {
return client.request({
path: `/repos/octocat/${repo}/issues`,
method: 'POST',
headers: { Authorization: `Bearer ${process.env.GH_TOKEN}` },
body: { title, body },
expectedCodes: [HTTP_CREATED]
});
}const user = await getUser('octocat');
if (user.ok) {
console.log('Login:', user.value.login);
} else if (
user.error.kind === 'unexpected_status' &&
user.error.statusCode === 404
) {
console.log('User not found');
} else {
console.error('Request failed:', user.error.kind);
}type GitHubUser = { login: string; id: number };
async function getUser(
username: string
): Promise<Result<GitHubUser, TrembitaRequestError>> {
const result = await client.request({
path: `/users/${username}`,
expectedCodes: [HTTP_OK]
});
// Optionally validate with Standard Schema (see Advanced Features)
return result;
}When you need raw status codes:
const health = await client.client({ path: '/health' });
if (health.ok) {
const { statusCode, body } = health.value;
if (statusCode === 200) {
console.log('Service healthy');
} else {
console.log('Degraded:', body);
}
} else {
console.error('Health check failed:', health.error.kind);
}Use createRetryingFetch to wrap fetch with exponential backoff:
import { createRetryingFetch } from 'trembita';
const retryingFetch = createRetryingFetch({
initialDelayMs: 100,
maxAttempts: 3,
shouldRetry: (statusCode) => statusCode >= 500 // Retry 5xx only
});
const api = createTrembita({
endpoint: 'https://api.example.com',
fetchImpl: retryingFetch
});Prevent cascading failures:
const api = createTrembita({
endpoint: 'https://api.example.com',
circuitBreaker: {
failureThreshold: 5, // open after 5 failures
cooldownMs: 30_000 // retry after 30s
}
});
// Later, when circuit is open:
const response = await api.value.request({ path: '/' });
if (!response.ok && response.error.kind === 'circuit_open') {
// Service is temporarily unavailable
console.log('Backing off...');
}Enable structured logging:
const api = createTrembita({
endpoint: 'https://api.example.com',
log: {
trace: (msg, meta) => console.log('[TRACE]', msg, meta),
debug: (msg, meta) => console.log('[DEBUG]', msg, meta),
info: (msg, meta) => console.log('[INFO]', msg, meta),
warn: (msg, meta) => console.warn('[WARN]', msg, meta),
error: (msg, meta) => console.error('[ERROR]', msg, meta)
}
});Logs are automatically redacted (auth headers, cookies, API keys hidden).
Validate response bodies at parse time:
import { requestWithStandardSchema, validateStandardSchema } from 'trembita';
import { z } from 'zod';
const userSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email()
});
// Validate response body
const result = await requestWithStandardSchema(client, {
path: '/users/123',
schema: userSchema,
expectedCodes: [HTTP_OK]
});
if (result.ok) {
// result.value is typed as Zod schema type
console.log(result.value.email);
} else if (result.error.kind === 'validation_failed') {
console.error('Invalid response:', result.error.issues);
}Build OpenTelemetry headers:
import { traceContextHeaders } from 'trembita';
const headers = traceContextHeaders({
traceId: '4bf92f3577b34da6a3ce929d0e0e4736',
spanId: '00f067aa0ba902b7',
traceFlags: '01',
traceState: 'congo=t61rcZ94t8w25c'
});
const response = await client.request({
path: '/api/resource',
headers // propagates trace to downstream service
});Generate type-safe paths from OpenAPI specs:
import { expandOpenapiPath } from '@trembita/openapi';
// paths comes from `openapi-typescript`
// type paths = { '/users/{id}': {...}, ... }
const expanded = expandOpenapiPath(paths, '/users/{id}', { id: '123' });
// → '/users/123'
if (expanded.ok) {
const result = await client.request({
path: expanded.value, // type-safe!
expectedCodes: [HTTP_OK]
});
}No global mocking needed:
import { vi } from 'vitest';
import { createTrembita, HTTP_OK } from 'trembita';
const mockFetch = vi.fn(() =>
Promise.resolve(new Response(JSON.stringify({ id: 1 }), { status: 200 }))
);
const api = createTrembita({
endpoint: 'https://api.test.com',
fetchImpl: mockFetch
});
const result = await api.value.request({
path: '/items',
expectedCodes: [HTTP_OK]
});
expect(result.ok).toBe(true);
expect(mockFetch).toHaveBeenCalledWith(
'https://api.test.com/items',
expect.any(Object)
);const mockFetch = vi.fn(() => Promise.reject(new Error('DNS failure')));
const api = createTrembita({
endpoint: 'https://api.test.com',
fetchImpl: mockFetch
});
const result = await api.value.request({ path: '/' });
expect(result.ok).toBe(false);
expect(result.error.kind).toBe('fetch_failed');const mockFetch = vi.fn(() =>
Promise.resolve(new Response('Not found', { status: 404 }))
);
const result = await api.value.request({
path: '/missing',
expectedCodes: [200, 201]
});
expect(result.ok).toBe(false);
expect(result.error.kind).toBe('unexpected_status');
expect(result.error.statusCode).toBe(404);import { createTrembita, HTTP_OK, TrembitaRequestError } from 'trembita';
// Define domain types
type User = { id: number; name: string };
type CreateUserPayload = Omit<User, 'id'>;
// Define Result type for clarity
type ApiResult<T> = Promise<Result<T, TrembitaRequestError>>;
class UserApi {
constructor(private client: TrembitaClient) {}
getUser(id: number): ApiResult<User> {
return this.client.request({
path: `/users/${id}`,
expectedCodes: [HTTP_OK]
});
}
createUser(payload: CreateUserPayload): ApiResult<User> {
return this.client.request({
path: '/users',
method: 'POST',
body: payload,
expectedCodes: [HTTP_CREATED]
});
}
}
// Usage
const api = createTrembita({ endpoint: 'https://api.example.com' });
if (!api.ok) throw api.error;
const users = new UserApi(api.value);
const user = await users.getUser(1);async function fetchWithFallback() {
let result = await primaryApi.request({ path: '/data' });
if (!result.ok && result.error.kind === 'fetch_failed') {
// Primary API is down, try backup
result = await backupApi.request({ path: '/data' });
}
return result;
}async function withAuth(path: string, token: string) {
return client.request({
path,
headers: {
Authorization: `Bearer ${token}`
}
});
}
// Usage
const data = await withAuth('/protected', process.env.API_TOKEN);async function fetchUsers(ids: number[]) {
const results = await Promise.all(
ids.map((id) =>
client.request({
path: `/users/${id}`,
expectedCodes: [HTTP_OK, 404]
})
)
);
const users = results.filter((r) => r.ok).map((r) => r.value);
const notFound = results
.filter((r) => !r.ok && r.error.kind === 'unexpected_status')
.map((r, i) => ids[i]);
return { users, notFound };
}- Browse examples/ for real-world use cases
- Check API docs for full type signatures
- Read CONTRIBUTING.md if you want to contribute
- See SPEC.md for design decisions
Q: Why Result instead of exceptions?
A: Exceptions hide error cases. Result forces you to handle both paths at
compile time, making bugs rarer.
Q: Can I use this in the browser?
A: Yes—it's ESM and works with any bundler (Vite, webpack, esbuild). Global
fetch and URL required.
Q: Why no middleware?
A: Simple functions are more composable. Wrap fetchImpl for retries, or
compose request helpers for auth.
Q: Can I use this with REST frameworks?
A: Yes. trembita is a client, not a server. Use with Next.js, Express,
Fastify, etc. to consume APIs.
Q: How do I add custom logic between requests?
A: Inject a custom fetchImpl that wraps the real fetch, or compose request
helpers with shared logic.