@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.
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" });
constres=await
constapi:Client<Api, ClientTypes>
api.get("/users/:id", {
params: {
id: number;
}
params: {
id: number
id: 42 } });
if (res.
ok: boolean
ok) {
console.log(
constres: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.
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.
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:
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:
constres=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) {
forawait (const
constevent:ReceivedEvent<{
type:"delta";
delta:string;
} | {
type:"done";
}>
eventof
constres:Success<200, undefined> & {
readonlyevents: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 (
constevent:ReceivedEvent<{
type:"delta";
delta:string;
} | {
type:"done";
}>
event.
data: {
type: "delta";
delta: string;
} | {
type: "done";
}
data.
type: "delta"|"done"
type==="delta") {
console.log(
constevent: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.
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.
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:
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 ${
consttoken: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() }).
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:
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.