# Trembita Learning Guide Learn to build type-safe, error-handling HTTP clients with **trembita** — from basics to advanced patterns. ## Table of Contents 1. [Core Concepts](#core-concepts) 2. [Getting Started](#getting-started) 3. [The Result Pattern](#the-result-pattern) 4. [Building a Client](#building-a-client) 5. [Error Handling](#error-handling) 6. [Advanced Features](#advanced-features) 7. [Testing](#testing) 8. [Common Patterns](#common-patterns) --- ## Core Concepts Trembita enforces **three principles**: ### 1. **Functional API (no classes)** Instead of `new Client()`, you call factory functions: ```typescript const result = createTrembita({ endpoint: '...' }); ``` ### 2. **`Result` for errors (no exceptions)** Every operation returns either `{ ok: true, value }` or `{ ok: false, error }`. No `try/catch` for normal HTTP outcomes: ```typescript const response = await client.request({ path: '/' }); if (!response.ok) { // handle response.error.kind } ``` ### 3. **Stdlib-first (zero dependencies)** Uses only `fetch` and `URL` from Node/browser. Inject your own `fetchImpl` for testing—no global mocking. --- ## Getting Started ### Install ```bash npm install trembita ``` ### Minimal example (1 minute) ```typescript import { 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:** 1. `createTrembita()` → validates options, returns `Result` 2. `.request()` → `Promise>` 3. Check `.ok` to narrow the type --- ## The Result Pattern ### What is `Result`? A simple discriminated union that forces you to handle both success and failure: ```typescript type Result = { ok: true; value: T } | { ok: false; error: E }; ``` **Why?** TypeScript narrows the type when you check `.ok`: ```typescript 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); } } ``` ### Extracting values safely ```typescript // 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; ``` ### Common Error Kinds | 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 | --- ## Building a Client ### Step 1: Create and validate ```typescript 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 ``` ### Step 2: Define request helpers ```typescript // 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] }); } ``` ### Step 3: Call and handle ```typescript 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); } ``` ### Step 4: Type the response (optional) ```typescript type GitHubUser = { login: string; id: number }; async function getUser( username: string ): Promise> { const result = await client.request({ path: `/users/${username}`, expectedCodes: [HTTP_OK] }); // Optionally validate with Standard Schema (see Advanced Features) return result; } ``` --- ## Error Handling ### Health checks and monitoring When you need raw status codes: ```typescript 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); } ``` ### Retry logic Use `createRetryingFetch` to wrap fetch with exponential backoff: ```typescript 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 }); ``` ### Circuit breaker pattern Prevent cascading failures: ```typescript 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...'); } ``` ### Logging Enable structured logging: ```typescript 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). --- ## Advanced Features ### 1. Standard Schema Validation Validate response bodies at parse time: ```typescript 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); } ``` ### 2. W3C Trace Context (Distributed Tracing) Build OpenTelemetry headers: ```typescript 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 }); ``` ### 3. OpenAPI paths (optional @trembita/openapi) Generate type-safe paths from OpenAPI specs: ```typescript 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] }); } ``` --- ## Testing ### Inject a mock fetch No global mocking needed: ```typescript 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) ); ``` ### Test error paths ```typescript 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'); ``` ### Test expected status codes ```typescript 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); ``` --- ## Common Patterns ### Pattern 1: Typed REST client ```typescript import { createTrembita, HTTP_OK, TrembitaRequestError } from 'trembita'; // Define domain types type User = { id: number; name: string }; type CreateUserPayload = Omit; // Define Result type for clarity type ApiResult = Promise>; class UserApi { constructor(private client: TrembitaClient) {} getUser(id: number): ApiResult { return this.client.request({ path: `/users/${id}`, expectedCodes: [HTTP_OK] }); } createUser(payload: CreateUserPayload): ApiResult { 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); ``` ### Pattern 2: Fallback cascade ```typescript 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; } ``` ### Pattern 3: Request middleware ```typescript 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); ``` ### Pattern 4: Batch requests ```typescript 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 }; } ``` --- ## Next Steps - **Browse [examples/](./examples/)** for real-world use cases - **Check [API docs](https://oleg-koval.github.io/trembita/)** for full type signatures - **Read [CONTRIBUTING.md](./CONTRIBUTING.md)** if you want to contribute - **See [SPEC.md](./SPEC.md)** for design decisions --- ## FAQ **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.