createClient<Routes>(baseUrl, options?) from bendo/client calls a Bendo app
with types generated from its routes. Paths read like the URL, captures are
function calls, and every call resolves to a result value instead of throwing.
import { createClient } from "bendo/client";
import type { Routes } from "./.bendo/routes";
import { schemas } from "./.bendo/routes.schemas";
const api = createClient<Routes>("http://localhost:8787", { schemas });
const todo = await api.todos({ id: 1 }).get();
if (todo.ok && todo.kind === "data") console.log(todo.data.title);
else if (todo.kind === "http") console.log(todo.status, todo.error);
await api.todos.post({ body: { title: "Write docs" } });
await api.todos.get({ query: { limit: 20, tag: ["home", "urgent"] } });For React, bendo/query builds TanStack Query options from these
calls.
Run bendo gen first. It writes .bendo/routes.ts (the route types)
and .bendo/routes.schemas.ts (the params and response schemas used for validation).
Rerun it after changing a route or schema.
Each /-separated part of a route path becomes one step:
| Pattern segment | Client |
|---|---|
todos |
property: api.todos |
:id, :id{int} |
call with an object holding that one key: api.todos({ id: 1 }) |
:id? (optional, trailing) |
the call may be left out: api.v1.get() or api.v1({ version: 2 }).get() |
*path (final wildcard) |
call: api.files({ path: "a/b.txt" }) |
* (final) |
call with the key "*" |
interior * |
no step; it matches zero segments |
assets* (prefix wildcard) |
property assets |
/ |
methods on the client itself: api.get() |
A path ends with a method: get, head, post, put, delete, options
or patch. A route registered with B.all has all seven. Extension methods
(such as PURGE), lowercase method tokens, CONNECT and TRACE have no
method key: fetch refuses the last two and the others cannot be told apart
from path segments.
A capture value's type comes from the route's params schema, so
api.todos({ id: true }) is a type error when id is an integer. Without a
params schema a capture takes string | number. Two routes that share a
capture position share one call, which takes any value either route accepts.
The value then picks the routes: if GET /items/:id takes id: "read" and
PUT /items/:id takes id: "write", api.items({ id: "write" }) has put but
no get, and the same holds for every route below the capture.
Captures are encoded with encodeURIComponent. A / inside a value travels as
%2F, which Bendo decodes back, so { path: "a/b" } reaches a wildcard as
a/b. Values . and .. cannot be sent, because the URL parser removes dot
segments; the call returns a request result without sending anything.
Static segments are sent as written. The URL parser percent-encodes spaces and
non-ASCII text in them, and Bendo compares static text without decoding, so a
route such as /café cannot be reached from fetch.
A method takes one optional object:
| Field | Type | Sent as |
|---|---|---|
query |
the route's query input type | the URL query |
headers |
the route's header input type | request headers, replacing defaults by name |
body |
the route's body input type | JSON with content-type: application/json |
signal |
AbortSignal |
passed to fetch |
The argument and each field are required exactly when the route's schema has
a required field. Without a schema, query and headers accept any record of
text values and body accepts any JSON value.
Query and header values follow Bendo's text coercion:
a string is sent as is, a number or bigint as its JavaScript string, a boolean
as true or false, and an array as the key repeated once per item.
undefined leaves the key out, so an empty array sends nothing. Headers take
one value per name: fetch joins repeated header values into one field (a, b),
which Bendo reads as a single value. So a header array has exactly one item
(["v"], even when the schema allows more), and a call with two items, or with
two spellings of one name such as X-A and x-a, is a request result.
Every request sends accept: application/json unless a default or call header
sets another accept, because Bendo renders error bodies as JSON only for an
explicit JSON Accept (decision 0011).
Every call resolves to a ClientResult<T>, where T is the route's response
type, or undefined for head. The promise never rejects.
| Result | When |
|---|---|
{ ok: true, kind: "data", status, data, response } |
a 2xx response other than 204 and 205 whose body passed validation |
{ ok: true, kind: "no-content", status, data: undefined, response } |
a 204 or 205; status is 204 | 205 |
{ ok: false, kind: "http", status, error, response } |
any status outside 200 to 299 |
{ ok: false, kind: "request", cause } |
the request could not be built, so nothing was sent |
{ ok: false, kind: "network", cause } |
no response: fetch rejected (refused connection, DNS, abort) or the body could not be read |
{ ok: false, kind: "invalid-response", status, issues, response } |
a 2xx body that is not valid JSON or fails the response schema |
data is parsed JSON when the response's content type is application/json
or +json, and the body text otherwise. A 204, a 205 and a successful HEAD
have data: undefined whatever their content type says, since none has a body.
Any route can answer 204 or 205 and Bendo never checks one against the response
schema, so data has the route's type only after narrowing on kind: "data".
A 304 is an http result like any status outside 200 to 299.
response is the Response with its body already read.
error is Bendo's error JSON when the body has its whole shape:
{ kind: "bendo", error: "Unprocessable Content", status: 422,
errors: [{ path: ["body", "title"], code: "constraint", message: "value does not satisfy scalar checks" }] }errors is the schema error list and is empty when the
body has none. Any other body is kept as { kind: "text", text }.
A request that cannot be built is a request result, kept apart from
network so a retry policy can skip it: retrying sends the same broken
request. Its cause is usually a TypeError naming the problem: a dot-segment
capture, a query or header value that is not text, a header with more than one
value, a header name or value HTTP cannot carry, a body without a JSON form, or
a capture call without exactly one key. The types reject most of these; they
cannot see dot segments, invalid header characters, or, for routes without a
body schema, bodies such as a function or a bigint.
issues lists { path, message } per failure; path holds keys and array
indexes into data.
With schemas, a 2xx body is checked against the response schema of every
route that could have answered the call (several when duplicate or B.all
routes share the path). A route qualifies when its method and path shape match.
Params schemas then narrow the list: if some of those routes accept the call's
captures, only they remain, so a route that only takes id: "read" is not
checked against the response of its id: "write" sibling. If none accepts the
captures, all of them remain. The exported params schema does not prove the
server enforced it: the app may export a separately supplied list, serve with
Validate.skip, or run middleware that answers before validation.
Captures are read as the server's text coercion reads them. A number capture
is admitted only as a JSON number token, compared with the bounds by exact
decimal value; an integer schema also requires a plain integer token. So
{ id: 7 } is the integer 7, "1e3" is not an integer, "007" is not a
number, and an unbounded number takes "1e400". The table holds bounds as
JavaScript numbers, so a bound beyond the double range leaves that side open.
The body passes when any remaining route's response schema accepts it. A route
without a response schema accepts any body, and so does a call no route in the
table describes. HEAD, 204 and 205 responses are never validated. A body that
is not JSON fails when a schema applies.
Pass validate: false to switch validation off, for example in production or
under a Content Security Policy without unsafe-eval. Then the body is
returned as data unchecked. Error responses are never validated.
The validator is Ajv, loaded on first use. Decision 0016 explains the choice and its limits, and the validator differences list where Ajv and Bendo's decoder disagree.
createClient<Routes>("https://api.example.com/v1", {
fetch: (input, init) => fetch(input, init), // inject for auth, retries or tests
headers: { authorization: `Bearer ${token}` }, // sent with every request
schemas, // from .bendo/routes.schemas.ts
validate: import.meta.env.DEV, // only with schemas
});The base URL keeps its path prefix and drops its query and fragment. An invalid
base URL throws from createClient. That is the only throw.
- A static segment named like a method key (
/files/get) can be followed by other segments but not by a capture call:api.files.get(...)sends a request. - A segment named
thencannot be reached: nodes returnundefinedforthenso that awaiting one does not send a request. - Default headers do not relax types: a header the route's schema requires must still be passed in each call.
Type checking stays linear in the number of routes:
timeout 60s bun scripts/client-types-bench.ts 400 checks 400 routes, each
called once, in about 0.55 s (0.22 s for 100, 0.32 s for 200), with the same
cost when every call passes a different capture literal (--distinct). The
exception is a capture whose routes disagree on its value type (--mixed): each
distinct literal type passed to it filters that capture's routes again, so 400
such routes called with 400 different literals take about 1.75 s. A value typed
string or number is one type however many calls pass it.
timeout 10m bun test --timeout 30000 packages/bendo/client compiles
tests/fixtures/client/app.bend, checks that bendo gen output matches the
committed packages/bendo/client/fixture, and calls the server for typed data,
404 and validation 422 errors, invalid responses, the development server's own
response check as a 500, HEAD on a JSON route,
encoding and serialization, a closed
port and an aborted signal. packages/bendo/client/types.check.ts holds the
type tests that bun run typecheck checks.