Typed client
zerolith does not know your server. What it knows is the contract the server publishes, an OpenAPI 3.x document, and @zerolith/client builds on that alone:
- The server publishes its contract, such as
openapi.json. zerolith-clientturns the contract into one TypeScript module,src/api.ts, with the types and a route table.createClientover that module checks every call against the contract: the operation's name, its parameters, its body, and what it returns.
Renaming a field on the server then fails tsc on the client, whatever language the server is written in.
Generate the contract module
npx zerolith-client openapi.json --out src/api.tsThe command writes three things: a type for every schema under components.schemas, an Api interface with one entry per operation, and a routes table with the method and path of each. Without --out it prints the module, and --name gives the interface another name. A schema whose type name would clash is written under the next free name with a number added, as Record2, with a comment that says why, and every reference to it follows: one that another schema takes first, as note-item and note_item both give NoteItem, one named like the interface or RouteTable, and one named like a built-in type the module writes, such as Record for an object of free keys or Blob for a binary response. A schema named like a built-in the module does not write keeps its name. One entry looks like this:
getNote: {
method: "get";
path: "/notes/{id}";
params: {
id: string;
};
output: Note;
};An operation is named by its operationId in camel case, or by its method and path when it has none, so GET /notes/{id}/tags becomes getNotesByIdTags. Two operations that resolve to the same name fail the generator, which asks for distinct operationId values.
How the document maps to types
| In the document | In the generated entry |
|---|---|
| Path parameters | params, one member per name |
| Query parameters | query, optional unless one of them is required; an optional parameter also accepts undefined, which is not sent |
A JSON request body, application/json or any +json type |
body, typed by its schema, optional unless the body is required |
A multipart/form-data, URL-encoded, or text/* request body |
body as FormData, URLSearchParams, or string |
| The 2xx responses | output: the union of their JSON types, string for text, Blob for any other type, such as application/octet-stream, and undefined for a response without content |
| No 2xx response | output from the default response, or unknown |
summary, description, and deprecated |
The entry's doc comment, with @deprecated when it applies |
allOf, oneOf, anyOf, and $ref in a schema |
An intersection, a union, or the referenced type; keywords beside them that shape a type, such as properties, intersect with them |
additionalOperations, OpenAPI 3.2's methods beyond the standard ones |
An entry per method, whose method is the name in lowercase, such as copy; a name that is not an HTTP method name fails the generator, since it would be written into the module |
| Header and cookie parameters, security schemes, and references outside the document | Left out; headers travel through the client's options |
A trace operation, or a CONNECT, TRACE, or TRACK entry under additionalOperations |
Left out, since fetch refuses to send those methods |
Call operations
import { createClient } from "@zerolith/client";
import { type Api, routes } from "./notes.api.ts";
const api = createClient<Api>(routes, { baseUrl: "https://notes.example" });
const note = await api.call("getNote", { params: { id: "1" } });
const page = await api.call("listNotes", { query: { q: "rout", limit: 10 } });call resolves with the operation's output. Its second argument holds the operation's params, query, and body, exactly as optional as the contract makes them, and it is omitted when the operation takes nothing. The request is built this way:
- The path, with each parameter encoded; a missing one throws a
TypeErrorthat names it, and so does a parameter of.or.., which the URL would otherwise read as a step to another path. - The query, with an array repeating its key, an object sent as JSON, and
undefinedandnullleft out, whateverstyleandexplodethe document declares for the parameter, which the client does not read yet. - The body as JSON with its content type, unless it is already a
FormData,URLSearchParams,Blob,ArrayBufferor typed array, string, or stream, which goes as it is. - An
Acceptheader asking for JSON or a problem, unless a header sets one.
A successful response resolves to its JSON, its text, or undefined when it has no content.
Read failures as problems
A response outside 2xx rejects with a ProblemError, so one catch handles every failure the server reports:
import { signal } from "zerolith";
import { ProblemError } from "@zerolith/client";
const notice = signal("");
try
{
await api.call("getNote", { params: { id: "missing" } });
}
catch (error)
{
if (!(error instanceof ProblemError))
{
throw error;
}
notice.set(error.problem.title ?? "Request failed"); // "No such note"
}| Member | Holds |
|---|---|
problem |
The RFC 9457 document the server sent, with the response's status filled in when it left it out, or a problem built from the status line when the body was something else |
response |
The Response itself |
message |
The problem's title, or HTTP and the status |
The same Problem type is what forms show and what the core exports, so one error shape runs through the whole application.
Query with signals
query wraps an operation in a resource. The function you pass is tracked: when a signal it reads changes, the operation is called again, the previous request is aborted, and a stale result is dropped. Returning undefined keeps the resource idle.
import { signal } from "zerolith";
const search = signal("rout");
const results = api.query("listNotes", () => ({ query: { q: search.get() } }));results is a resource like any other, as the signals guide describes: results.get() is the latest output, kept while the next request runs, results.pending is true while a request is in flight, and results.error holds the ProblemError of a failed call.
Options
| Option | What it does |
|---|---|
baseUrl |
Prepended to every path. It defaults to the empty string, the page's origin in a browser; pass an absolute URL anywhere else |
headers |
Sent with every request, or computed per request when given as a function, which is where a bearer token goes |
transport |
Replaces fetch: a function from a Request to a promise of a Response. A test can answer in process, and an Electron renderer can forward requests over a message port |
Each call also takes options of its own as its last argument, a signal and headers that override the client's. request takes the same arguments as call and returns the Request a call would send, without sending it.
Over a message port
Where fetch cannot reach the server, the same client runs over a message port: an Electron renderer behind contextBridge, a worker, or a test. servePort, from @zerolith/client/port, answers requests where the handler lives; portTransport sends them from where the client lives.
import { createClient } from "@zerolith/client";
import { portTransport, servePort } from "@zerolith/client/port";
import { type Api, routes } from "./notes.api.ts";
const { port1, port2 } = new MessageChannel();
// Where the handler runs, such as the Electron main process:
servePort(port2, (request) =>
{
const id = new URL(request.url).pathname.split("/").at(-1);
const note = { id, title: "From main", tags: [], visibility: "private" };
return Response.json(note);
});
// Where the page runs, with port1 handed over through the preload script:
const api = createClient<Api>(routes, {
baseUrl: "app://notes",
transport: portTransport(port1)
});
const note = await api.call("getNote", { params: { id: "7" } });In Electron, the main process creates a MessageChannelMain, keeps one port for servePort, and posts the other to the renderer, whose preload script forwards it to the page with window.postMessage; Electron's MessagePortMain and the page's MessagePort are both accepted. Problems cross unchanged, a handler that throws answers with a 500 problem, and aborting a call rejects it at once, so the client's error handling is the same on every transport.