Skip to content

@tetsujs/client

@tetsujs/client calls a Tetsu application with the types its routes already declare. It is a small fetch wrapper with no dependencies. A frontend gets the types from a contract, a .d.ts file generated from the application. Setting it up in Next.js or SvelteKit is in the guide.

Terminal window
bun add @tetsujs/client
import { createClient } from "@tetsujs/client";
import type { Api } from "./api.contract";
const
const api: Client<Api, ClientTypes>
api
= createClient<Api>({
baseUrl?: string | URL | undefined

Where the application is, put before every path — https://api.example.com or https://example.com/api. A trailing slash is dropped. Empty by default: the paths stay relative, for a fetch that resolves them itself.

baseUrl
: "https://api.example.com" });
const res = await
const api: Client<Api, ClientTypes>
api
.get("/users/:id", {
params: {
id: number;
}
params
: {
id: number
id
: 42 } });
if (res.
ok: boolean
ok
) {
console.log(
const res: Success<200, {
id: number;
name: string;
createdAt: string;
}>
res
.
data: {
id: number;
name: string;
createdAt: string;
}

The body, parsed when it is JSON; undefined otherwise.

data
.createdAt);
createdAt: string
} else {
console.log(
const res: Failure<404, {
status: 404;
message: string;
error: "USER_NOT_FOUND";
}> | Failure<400 | 401 | 402 | 403 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 421 | 422 | 423 | 424 | 425 | 426 | 428 | 429 | 431 | 451, ErrorBody> | Failure<500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 510 | 511, ErrorBody | undefined>
res
.
status: 404 | 400 | 401 | 402 | 403 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 421 | 422 | 423 | 424 | 425 | 426 | 428 | 429 | 431 | 451 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 510 | 511
status
);
}

api.get suggests the paths that answer GET. A wrong path, a missing parameter or a wrong body is a compile error. data is what JSON delivers, so a Date is a string.

Api comes from the contract. In tests and projects on Bun, which have Bun’s types anyway, createClient<typeof app>() reads the application’s type directly.

A call takes the path, then the parts the route declares:

Option
params the path’s :name segments
query the query string; an array repeats its key
body the body: an object goes as JSON; required with a schema, {} when every field is optional
form the body of a route with bodyType: "form", sent as FormData
headers the headers the route declares, and any other
init the rest of RequestInit: signal, credentials…

Parameters, the query and headers travel as text; a Date goes as its ISO string. A part the route does not declare is a compile error. api.url(path, { params, query }) returns the address without sending, for an image or an EventSource.

A call does not throw on an error status. It resolves to one member per status the route declares, told apart by ok and status:

if (res.
status: 404 | 200 | 400 | 401 | 402 | 403 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 421 | 422 | 423 | 424 | 425 | 426 | 428 | 429 | 431 | 451 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 510 | 511
status
=== 404) {
console.log(
const res: Failure<404, {
status: 404;
message: string;
error: "USER_NOT_FOUND";
}>
res
.
error: {
status: 404;
message: string;
error: "USER_NOT_FOUND";
}

The body, parsed when it is JSON; undefined otherwise.

error
.error);
error: "USER_NOT_FOUND"
}

A status the route does not declare comes with the core’s error envelope. Only what is not a response throws: a network failure, an aborted request, JSON that does not parse; and, before anything is sent, a TypeError for an address that would go elsewhere: a parameter that is missing, empty, . or .., or a path that does not start with a single /.

unwrap() returns the data, or throws a ClientError with status, body and response:

const
const user: unknown
user
= unwrap(await
const api: Client<Untyped, ClientTypes>
api
.
get: <Untyped, ClientTypes, "/users/:id">(this: Client<Untyped, ClientTypes>, path: "/users/:id", options?: CallOptions<LooseRoute, never> | undefined) => Promise<Result<LooseRoute, ErrorBody>>
get
("/users/:id", {
params?: Readonly<Record<string, unknown>> | undefined
params
: {
id: number
id
} }));

A success that comes with content-type: text/event-stream has events: the events of the stream, read as the loop asks for them. The route’s body declares the data of one event:

const res = await api.post("/agents/:name/stream", {
params: {
readonly name: string | number;
}
params
: {
name: string | number
name
: "chat" },
body: {
input: string;
}
body
: {
input: string
input
: "Hello" },
});
if (res.
ok: boolean
ok
) {
for await (const
const event: ReceivedEvent<{
type: "delta";
delta: string;
} | {
type: "done";
}>
event
of
const res: Success<200, undefined> & {
readonly events: AsyncIterable<ReceivedEvent<{
type: "delta";
delta: string;
} | {
type: "done";
}>>;
}
res
.
events: AsyncIterable<ReceivedEvent<{
type: "delta";
delta: string;
} | {
type: "done";
}>>

The events of the stream, read as they are iterated. Leaving the loop closes the connection, and the call's signal aborts it; a stream that is never read stays open until the signal aborts it or response.body is cancelled. Read once.

events
) {
if (
const event: ReceivedEvent<{
type: "delta";
delta: string;
} | {
type: "done";
}>
event
.
data: {
type: "delta";
delta: string;
} | {
type: "done";
}
data
.
type: "delta" | "done"
type
=== "delta") {
console.log(
const event: ReceivedEvent<{
type: "delta";
delta: string;
} | {
type: "done";
}>
event
.
data: {
type: "delta";
delta: string;
}
data
.
delta: string
delta
);
}
}
}

An event is { data, text, event?, id?, retry? }. text is the data as it came; data is that text parsed as JSON, and undefined when it is not JSON. sse() sends a string as it is and anything else as JSON, so a body that may be a string, such as z.string(), types data as unknown, as no body does. A stream of text reads text: a token such as " 42" would be the number 42 as data. event is the name the server gave the event, there only when it gave one; id and retry are the event’s own. An id or a retry sent without data is no event, and is lost.

A failure that comes before the stream, such as the 404 above, is an ordinary failure without events. On a route that declares no response, events is optional: there when the response is a stream.

The body is one stream, read once, and the connection stays open while it is:

  • Leaving the loop, by break, return or a throw, closes it.
  • A stream that is never read keeps it open: read it, or abort the call. unwrap() throws a TypeError for a stream, and closes it.
  • The call’s init.signal closes it from outside, for a timeout or when the user leaves. The loop then throws the signal’s reason: an AbortError for abort(), a TimeoutError for AbortSignal.timeout().
  • A network failure throws. A connection that dies without closing is not noticed: the loop waits until the signal aborts it.
  • A handler that fails mid-stream ends it like a stream that is done. Send an event that says so, such as done or error, and check for it.

There is no reconnecting: to resume, send last-event-id in the call’s headers with the last id read. For a GET without headers, new EventSource(api.url(path)) reconnects by itself, untyped. For TanStack Query, see the recipe.

Option Default
baseUrl "" put before every path
headers none sent with every request; a function is called for each one
fetch the global fetch what sends the request: SvelteKit’s event.fetch (with a hook that lets content-type through), a test client, a wrapper
init none the rest of RequestInit for every request: credentials, cache, signal

A client is a plain object, cheap enough to create one per request.

A frontend on another site than the API sends its cookies with credentials: "include", set once for the client:

import { createClient } from "@tetsujs/client";
const
const api: Client<FromRoutes<RoutesOf<readonly []>>, ClientTypes>
api
= createClient<typeof app>({
baseUrl?: string | URL | undefined

Where the application is, put before every path — https://api.example.com or https://example.com/api. A trailing slash is dropped. Empty by default: the paths stay relative, for a fetch that resolves them itself.

baseUrl
: "https://api.example.com",
init?: Omit<RequestInit, "method" | "headers" | "body"> | undefined

The rest of fetch's options for every request — credentials, cache, signal. A call's own init wins key by key, except the signal: either one aborts the request.

init
: {
credentials?: RequestCredentials | undefined

A string indicating whether credentials will be sent with the request always, never, or only when sent to a same-origin URL. Sets request's credentials.

credentials
: "include" },
});

A call’s own init wins over the client’s key by key. A signal is the exception: the client’s and the call’s both abort the request.

A second type argument names the headers the client sends itself, so a route that requires one does not require it of each call:

import { createClient } from "@tetsujs/client";
const
const api: Client<FromRoutes<RoutesOf<readonly []>>, {
headers: "authorization";
}>
api
= createClient<typeof app, {
headers: "authorization"
headers
: "authorization" }>({
headers?: HeadersInit | (() => HeadersInput | Promise<HeadersInput>) | undefined

Headers sent with every request, or a function that returns them — asked again for each request, so a token read from a store is always the current one. A header a call sets wins over these.

headers
: () => ({
authorization: string
authorization
: `Bearer ${
const token: string
token
}` }),
});

For an application with its own error format, add error: AppError next to headers.

In tests, the core’s test client is a fetch with a cookie jar: createClient<typeof app>({ fetch: request.client() }).

tetsu-client contract writes the contract of the application a module exports:

Terminal window
bunx tetsu-client contract src/app.ts --out ../web/src/api.contract.d.ts
Option
--out <path> where to write; printed without it
--export <name> the export with the application, or a function that builds it; app by default
--check write nothing, fail when the file is out of date
--watch write again whenever the application changes

--name and --tsconfig change the interface’s name and the tsconfig.json used. The contract holds plain types only: no server code, no validators, no Bun. A brand is dropped and an enum becomes a union of its values. A type it cannot hold, such as a function, is an error that says where.

The command runs on Bun. On first run, Bun downloads TypeScript 6.0.3 into its own cache to work the types out. From a script, generateContract() takes the same options and returns the contract’s text:

import { generateContract } from "@tetsujs/client/contract";
const text = await generateContract({ entry: "src/app.ts", export: "app" });

@tetsujs/client/tanstack turns a client into TanStack Query options for any adapter:

import { queryUtils } from "@tetsujs/client/tanstack";
const
const q: QueryUtils<Untyped, ClientTypes>
q
= queryUtils(
const api: Client<Untyped, ClientTypes>
api
);
q.get(path).queryOptions(call) a query
q.get(path).infiniteOptions({ input, … }) an infinite query; input turns a page param into the call
q.post(path).mutationOptions() a mutation; its variables are the call
q.get(path).key() the start of the route’s keys, for invalidateQueries

A query’s error is a ClientError: error.status === 404 narrows error.body to that status’s body. Its key is [method, path, call], the call without its init and without what is undefined, and { infinite: true } after it for an infinite query. It is typed, so getQueryData knows what it holds. The types need @tanstack/query-core, which every adapter installs. Usage is in the guide.

  • A declared 404 or 422 also covers the router’s 404 and the validation 422.
  • fetch follows redirects, so a 3xx arrives as the target’s response.
  • A string body goes as text: for a JSON route that takes a string, send JSON.stringify(text).
  • A route with only rawBody: true takes no body from the client: give it a bodyType.
  • A query or form array of one item reaches the server as a string, and an empty one is not sent at all; see Converting strings.
  • z.coerce.boolean() reads the text "false" as true: the client sends false as "false". Use z.stringbool() in a query, a form or headers.
  • const { get } = api loses the types: call api.get().
  • An undeclared 5xx from a proxy may have no body, so its error can be undefined.
  • In TanStack Query, a network failure is a TypeError, not a ClientError; error?.status is safe either way.
  • With pnpm, keep one version of @tanstack/query-core, or the keys lose their types.
  • On TypeScript 5.7, type the client with an interface: interface Api extends ContractOf<typeof app> {}.