Documentation
Typed Client
The typed client wraps fetch with a consistent response shape, injectable transport, and full TypeScript alignment with your contract definitions. Call any HTTP method with a single import and get predictable, structured responses every time.
Creating a client#
createFXClient takes a base URL and an optional options object. The base URL is prepended to every path you pass to the individual method calls, so you only configure it once. Import it from @kynetra/fx.
import { createFXClient } from '@kynetra/fx' const client = createFXClient('https://api.example.com') // With client-level optionsconst authedClient = createFXClient('https://api.example.com', { headers: { Authorization: 'Bearer my-token', 'X-App-Version': '2.0.0', },})Every request made through authedClient will carry the headers you set here. Per-request headers are merged on top of these, so you can override individual fields without creating a new client.
Making requests#
The client exposes GET, POST, PUT, PATCH, and DELETE methods. The first argument is always a path that starts with / — it is appended to the base URL you provided when creating the client.
import { createFXClient } from '@kynetra/fx' const client = createFXClient('https://api.example.com', { headers: { Authorization: 'Bearer token' },}) // GET with query parametersconst list = await client.GET('/users', { query: { page: '1', limit: '20', role: 'admin' },}) // POST with a JSON bodyconst created = await client.POST('/users', { body: { name: 'Alice', email: 'alice@example.com', role: 'member' },}) // PUT — full replacementconst replaced = await client.PUT('/users/42', { body: { name: 'Alice Smith', email: 'alice@example.com', role: 'admin' },}) // PATCH — partial updateconst patched = await client.PATCH('/users/42', { body: { role: 'admin' },}) // DELETEconst deleted = await client.DELETE('/users/42')For bodies, the client serialises the value as JSON and sets Content-Type: application/json automatically. You do not need to call JSON.stringify yourself.
Path parameters
Path parameters are interpolated inline — build the path string yourself before passing it in. This keeps the method signature simple and gives TypeScript full visibility of the literal string you are calling.
const userId = '42'const orgId = 'acme' // Interpolate directly into the pathconst user = await client.GET(`/orgs/${orgId}/users/${userId}`)const memberships = await client.GET(`/orgs/${orgId}/users/${userId}/memberships`, { query: { status: 'active' },})The response shape#
Every method returns a Promise that resolves to a plain object with four fields: ok, status, data, and headers. There is no throwing on non-2xx responses — success and error paths are handled uniformly.
const res = await client.GET('/users/42') // ok is true when status < 400if (res.ok) { // data is parsed JSON when Content-Type is application/json console.log(res.data) // { id: 42, name: 'Alice', role: 'admin' }} else { // data still contains the error body — useful for validation errors console.error(res.status, res.data)} // Access response headers directlyconst rateLimit = res.headers.get('X-RateLimit-Remaining')ok—boolean.truewhen the HTTP status code is below 400.status—number. The raw HTTP status code from the server.data—unknown. Parsed JSON when the response has a JSONContent-Type; a plain text string otherwise.headers— the standardHeadersobject from the underlying fetch response.
Request options#
Pass an options object as the second argument to any method. All fields are optional and can be combined freely.
| Name | Type | Description |
|---|---|---|
| body | unknown | Request body. Serialised as JSON and sent with POST, PUT, and PATCH requests. Sets Content-Type: application/json automatically. |
| query | Record<string, string | number | boolean | undefined> | Key-value pairs appended to the URL as a query string. Undefined values are omitted. |
| headers | Record<string, string> | Per-request headers merged with the client-level headers. Keys in this object override matching client-level header keys. |
| signal | AbortSignal | An AbortSignal for request cancellation. Pass the signal from an AbortController to cancel in-flight requests. |
Client-level options#
Options passed to createFXClient apply to every request the client makes. They are the right place for authentication headers and custom transport.
| Name | Type | Description |
|---|---|---|
| fetch | typeof fetch | A custom fetch implementation used in place of the global fetch. Useful for test doubles, Cloudflare Service Bindings, and edge runtimes that expose a scoped fetch. |
| headers | Record<string, string> | Headers sent with every request. Per-request headers are merged on top of these, so individual calls can add or override specific fields. |
Injectable fetch#
Because the client accepts a custom fetch function, you can swap out the transport layer without changing any call-site code. This is useful in several situations.
- Test doubles — replace fetch with a stub that returns fixture data so tests run without hitting the network.
- Cloudflare Service Bindings — Workers that call another Worker internally receive a scoped
fetchon the binding object. Inject it to route requests through the binding rather than the public internet. - Edge runtimes — some runtimes expose a fetch variant with extra capabilities such as specifying a datacenter or backend. Inject it without touching the rest of your client code.
import { createFXClient } from '@kynetra/fx' // --- Test double ---const mockFetch: typeof fetch = async () => { return new Response(JSON.stringify({ id: 1, name: 'Alice' }), { status: 200, headers: { 'Content-Type': 'application/json' }, })} const testClient = createFXClient('https://api.example.com', { fetch: mockFetch,}) const res = await testClient.GET('/users/1')console.log(res.data) // { id: 1, name: 'Alice' }import { createFXClient } from '@kynetra/fx' // In a Cloudflare Worker, env.USER_SERVICE is a bound Workerreturn { async fetch(request: Request, env: Env) { const client = createFXClient('https://user-service', { fetch: env.USER_SERVICE.fetch.bind(env.USER_SERVICE), headers: { 'X-Internal': '1' }, }) const res = await client.GET('/users/me') return Response.json(res.data) },}Error handling#
When a server returns a 4xx or 5xx status, ok is false but the response is still fully resolved — there is no thrown exception. The data field holds the error body exactly as the server sent it, which lets you surface structured errors without wrapping calls in try/catch.
import { createFXClient } from '@kynetra/fx' const client = createFXClient('https://api.example.com') const res = await client.POST('/users', { body: { email: 'not-an-email' }, // intentionally invalid}) if (!res.ok) { if (res.status === 422) { // FX_VALIDATION_ERROR from the server // { code: 'FX_VALIDATION_ERROR', issues: [...] } console.error('Validation failed:', res.data) } else if (res.status === 401) { console.error('Unauthenticated — refresh your token') } else if (res.status === 403) { console.error('Forbidden — insufficient permissions') } else { console.error(`Unexpected error ${res.status}:`, res.data) }} else { console.log('Created:', res.data)}The 422 FX_VALIDATION_ERROR response is emitted automatically by any contract route that has an input or query schema. See Validation for the full error shape and how to narrow the type on the client.
Tip
AbortSignal from an AbortController. When you call controller.abort(), the underlying fetch is cancelled and the promise rejects with an AbortError. Wrap the call in try/catch and check err.name === 'AbortError' to distinguish cancellation from genuine network failures.With contracts#
Assign a fluent contract chain, then pass typeof app to the client. TypeScript derives valid method/path pairs, body and query fields, concrete path parameters, and response data from the schemas. No code generation is required.
import { createFX, createFXClient, fx } from '@kynetra/fx' export const app = createFX().route({ method: 'GET', path: '/users/:id', query: fx.object({ includeTeams: fx.optional(fx.boolean()) }), output: fx.object({ id: fx.string(), name: fx.string() }), handler: (ctx) => ctx.json({ id: ctx.params.id ?? '', name: 'Ada' }),}) const client = createFXClient<typeof app>('https://api.example.com')const res = await client.GET('/users/99', { query: { includeTeams: true },}) res.data.name // string // Compile errors: wrong method, path, query, body, or missing required options.// client.POST('/users/99') // Escape hatch for endpoints outside the contract set.await client.request('GET', '/runtime-selected-path')Note
const app = createFX().route(...).route(...); mutating a previously declared variable cannot widen that variable's static TypeScript type.See Contracts for how to define route schemas on the server side, and OpenAPI for generating a machine-readable spec from those same definitions.