TypeScript client
Generate a typed TS client for your REST API — typed queries with autocompleting filters, create/update DTOs that respect noform/noedit, and typed realtime subscriptions.
TypeScript client
umbral typegen gives your frontend the shapes. umbral gen-client gives it the client: a small, dependency-free TypeScript SDK where querying your API is typed end to end — and a filter map autocompletes to exactly the fields this model lets you filter on, with the right value types.
cargo run -- gen-client --out web/src/api/That writes two files:
client.js— the whole runtime, in one self-contained ES module. No imports, no build step: load it from a plain<script type="module">, or let any bundler take it as-is.client.d.ts— every type: row interfaces, choice unions, aFilters/Ordering/Create/Updateper model, the list envelope matching your paginator, and the class declarations.
Why no .ts runtime? TypeScript types erase — your row and filter types compile to zero JavaScript. So shipping .js + .d.ts means the runtime exists exactly once (no bundler, no transpile step, no second copy to drift), while import { Umbral } from "./api/client" still type-checks fully: TS resolves the .d.ts for types, your bundler resolves the .js for code. It's the shape every published SDK uses — and it means a plain-JavaScript app can use the SDK with no toolchain at all.
<!-- No build step, no bundler, no TypeScript. --><script type="module"> import { Umbral } from "/api/client.js"; const api = new Umbral(""); const page = await api.from("post").filter({ status: "published" }).list();</script>Using it from TypeScript
You just import it — you never reference the .js yourself. TypeScript never reads client.js; it finds client.d.ts sitting beside it and takes the types from there, while your bundler resolves client.js for the code. It's the same mechanism every npm package uses.
import { Umbral, UmbralError, type Post } from "./api/client";// ^^^^^^ value (client.js at runtime) ^^^^ type (client.d.ts) const api = new Umbral("https://api.example.com", { token });const page = await api.from("post").filter({ title__contains: "x" }).page(1).list();page.results; // Post[] — fully typedYour moduleResolution | Import as |
|---|---|
bundler (Vite, Next, esbuild) | "./api/client" |
node (webpack, CRA, older tsconfig) | "./api/client" |
nodenext (native ESM) | "./api/client.js" |
allowJs is not required. It's a common assumption that importing a .js file needs it — it doesn't, because TypeScript reads the .d.ts, not the JavaScript. This type-checks with allowJs: false (the default). Under native ESM (nodenext) write the .js extension, as ESM requires for every import; TypeScript maps client.js to client.d.ts for you.
Generate and use
Register the plugins
gen-client reads the config that RestPlugin + OpenApiPlugin publish when your routes are built — so both must be on the app. No server or database is needed at generate time; it's an offline step.
App::builder() .plugin(RestPlugin::default()) .plugin(OpenApiPlugin::default()) // ... your models .build()Generate into your frontend
Point --out at a folder in your frontend and run it from your app crate. Commit the two files it writes.
cargo run -- gen-client --out web/src/api/Import and call
client.js is dependency-free and client.d.ts sits beside it, so one import gets you the runtime and the types.
import { Umbral, type Post } from "./api/client"; const api = new Umbral("https://api.example.com");const post = await api.get("post", 42); // typed: PostKeep it in sync
Using it
import { Umbral } from "./api/client"; const api = new Umbral("https://api.example.com", { token: authToken }); // `"post"` autocompletes to your exposed tables. The filter object autocompletes// to the fields `post` actually lets you filter on, each with its real type.const page = await api .from("post") .filter({ status: "published", views__gte: 100, title__contains: "rust" }) .orderBy("-published_at") .list(); page.results; // Post[]page.count; // number const post = await api.get("post", 42); // PostWhat the types guarantee
Everything the REST list endpoint accepts is encoded in the types, so the mistakes a hand-written client makes become compile errors:
Only real filters
filter({ status, views__gte, title__contains, ... }) — the keys are exactly the (field, lookup) pairs this model accepts. An unknown field is a compile error. The primary key isn't filterable, matching the backend.
FK values are the target's PK
An FK filter takes the referenced model's primary-key type. If Author is keyed by a String slug, filter({ author: 'ada' }) — passing a number is a type error. The case hand-written clients always get wrong.
Choices are unions
filter({ status: 'publshed' }) fails at tsc and suggests 'published'. status__in takes PostStatus[].
Typed ordering
orderBy('-views', 'title') — each field is checked; a typo is rejected. Prefix - for descending.
The lookups per field follow the REST contract: comparisons (__gte, __lt) on numeric and temporal fields, __contains / __icontains / __startswith on text, __in everywhere, __isnull on nullable fields.
Writing data
create, update, and delete are typed too, with separate create and update bodies that reflect what the server actually accepts:
const post = await api.create("post", { title: "Hello", status: "draft", slug: "hello-world", // set once, here}); await api.update("post", post.id, { title: "Hello, world" }); // PATCH, partialawait api.delete("post", post.id);The body types drop the columns the server manages, so you can't send them by accident:
- The primary key,
auto_now/auto_now_addtimestamps,#[umbral(privileged)](the mass-assignment guard), and#[umbral(noform)]fields are omitted from both create and update. - A
#[umbral(noedit)]field is in the create body but not the update body — the set-once pattern. You can create aslugor ausername; you can't change it.api.update("post", id, { slug })is a compile error. - On create, a field is required unless it is nullable or has a server default. On update every field is optional (it's a PATCH).
The id in get / update / delete is the model's own primary-key type — number for an i64 PK, string for a Uuid or String-slug PK. api.get("ticket", 5) where ticket is Uuid-keyed is a compile error; it wants a string. Each resource carries its own id type, so this is checked per model, not against a global union.
Paging through results
The list builder exposes exactly the paging controls your paginator reads, and list() returns the envelope your paginator emits — both typed:
Sessions: log in, log out, who am I
If your app mounts AuthPlugin::with_default_routes(), the client grows an auth namespace. Signing in is enough — the token is stored on the client and every later request sends it, so you never thread a token through call sites:
const api = new Umbral("https://api.example.com"); const who = await api.auth.me(); // AuthUser | null — null means signed outconst { user } = await api.auth.login({ username, password }); await api.from("post").create({ title: "Hi" }); // already authenticated await api.auth.logout(); // token cleared; later calls are anonymousme()resolvesnullwhen signed out, it doesn't throw — a 401 is the answer to "am I logged in?", not a failure. Any other error still throws.logout()drops the token locally even if the server call fails, so a user who clicks "log out" is never left holding a live credential.- The types come from the schemas your auth plugin actually publishes, and the endpoints are discovered by
operationId— so a plugin mounted at a custom prefix still generates a working client, and an app with no auth plugin generates noauthnamespace at all.
The client never writes your token to localStorage. Anything in web storage is readable by any XSS on the page. Browsers already receive an httpOnly session cookie from login, which is the safer default and needs no work from you. If you do need the bearer token across reloads (mobile/CLI, or a cookie-less setup), persist it yourself via onToken — so that trade-off is one you made on purpose:
new Umbral(url, { token: sessionStorage.getItem("t") ?? undefined, // your choice, your risk onToken: (t) => t ? sessionStorage.setItem("t", t) : sessionStorage.removeItem("t"),});Optimistic updates
The pattern that keeps a UI fast without letting it lie: apply locally, send, reconcile — and let realtime be the source of truth.
async function toggleRsvp(id: number, going: boolean) { const prev = cache.get(id); cache.set(id, { ...prev, going }); // 1. optimistic: paint it now try { const row = await api.update("rsvp", id, { going }); cache.set(id, row); // 2. reconcile with the server's row } catch (err) { cache.set(id, prev); // 3. roll back on failure throw err; }}Two rules make this safe, and both are where hand-rolled versions go wrong:
- Reconcile with the server's response, don't keep your guess. The server may normalise, stamp
updated_at, or reject part of the write. Step 2 overwrites the optimistic value rather than leaving it. - Don't let your own optimistic write fight the realtime event. When you also subscribe with
.on(...), anupdatedevent for a row you just wrote will arrive and can overwrite a newer local edit with an older server one. Key the write and ignore the echo, or simply treat the realtime event as "this row changed — refetch it" and let the refetch win:
api.on("rsvp", { updated(row) { if (inFlight.has(row.id)) return; // my own write; step 2 already handled it cache.set(row.id, row); },}, { group: "match:42" });Handling errors
Any non-2xx response throws UmbralError with the status and the parsed body, so failures surface loudly instead of returning a half-typed object. A 204 No Content (what delete returns) resolves to null.
import { Umbral, UmbralError } from "./api/client"; try { const post = await api.get("post", 42);} catch (err) { if (err instanceof UmbralError) { if (err.status === 404) { /* not found */ } console.error(err.status, err.body); // body is the API's error JSON } else { throw err; // network / unexpected }}It matches your API, exactly
The generator reads the same model registry and per-resource config that renders your OpenAPI document — so it reflects the surface you actually serve:
- A field you
hide(...)isn't a filter key. - A resource with filtering disabled gets an empty
Filterstype. - Your configured base path (
/api,/v1, …) is baked into the request URLs. - The list envelope matches your paginator —
{results, count}for none, plustotal_pages/current_page/next/… for page-number,limit/offset/… for limit-offset. A custom paginator that declares its shape (Pagination::schema()) is emitted fully typed — its own envelope keys (e.g.next_cursor: string | null) and one builder method per query param (e.g..cursor(...)). One that doesn't declare a shape gets an honest open envelope (results?,count?, plus an index signature) and the generic.param(key, value)escape hatch — which every query has, for any param the typed methods don't cover.
Authentication
The client's auth surface is read from your API's declared OpenAPI security scheme — nothing is hardcoded, so a nonstandard token prefix or a custom header name both come out right:
new Umbral(url, { token }); // Authorization: <prefix> <token>new Umbral(url, { token, tokenPrefix: "Token" }); // override the prefixnew Umbral(url, { apiKey }); // <declared-header>: <apiKey>new Umbral(url, { apiKey, apiKeyHeader: "x-key" }); // override the headernew Umbral(url, { getAuthHeaders: async () => ({ Authorization: `Bearer ${await fresh()}` }) });- A
http/ bearer-style scheme sets theAuthorizationprefix from the scheme'sschemefield —bearer→Bearer,token→Token. That's the default fortoken; override per-request withtokenPrefix. - An
apiKey/ header scheme bakes its declarednameas the default header forapiKey(X-API-Key,X-Umbral-Api-Key, whatever you declared); override withapiKeyHeader. - An
apiKey/ cookie (session) scheme makes the client send credentials by default (fetchcredentials: "include"). getAuthHeaders()is always available and merged last (it wins) — for a rotating JWT, a refresh flow, or request signing.
With no scheme declared, the client still offers a sensible generic surface (Bearer token, X-API-Key apiKey, and the dynamic hook).
It runs offline as a CLI step — no server, no database — because that config is published when your app's routes are built.
Keep it honest in CI
--check regenerates in memory and compares, writing nothing. It exits non-zero when the checked-in files have drifted from the models, so a schema or resource change can't merge next to a stale client:
cargo run -- gen-client --out web/src/api/ --checkRealtime
The same client subscribes to live model-change events. client.on(table, handlers, { group }) routes created / updated / deleted to typed handlers:
const sub = api.on("post", { created(row) { cache.add(row); }, updated(row) { cache.update(row.id, row); }, deleted(row) { cache.remove(row.id); },}, { group: "public:posts" }); // latersub.close();"post" autocompletes to your exposed tables, and each handler's row is typed. It reflects your server-side expose(...) projection — the id by default (Partial<Post>), the whole row if you all_fields() — so the common pattern is "row N changed, refetch it through the typed query client above."
Wire the backend once with the realtime plugin:
RealtimePlugin::new() .with_auth_sessions() .expose::<Post>(Expose::to_group("public:posts"))Under the hood .on(...) does not open its own connection. It loads the realtime plugin's already-served /realtime/client.js and routes through umbral.realtime.model(...) — so it inherits the hard parts for free:
- One SSE connection shared across every tab (via
SharedWorker, union-routed). A client that opened anEventSourceper subscription would use one connection per model and exhaust the browser's ~6-connections-per-origin cap at six subscriptions. - Presence,
@broadcast/@user:channel routing, and graceful degradation (SharedWorker → EventSource → no-opunder CSP).
group is required and must match the group you expose(...)-d to. The realtime base path defaults to /realtime; override it with new Umbral(url, { realtimePath: "/rt" }) if you remounted the plugin. .on(...) returns synchronously and is a no-op during SSR (no document), so a component can subscribe on mount and still render on the server.
Hidden fields are write-only
hide(...) is response-only, and the client mirrors that precisely:
- A hidden column (a
password_hash, an internalcost) is not in the row type — the API never returns it, so the type never claims it. - It's not a filter or ordering key.
- It is in the create/update body — a hidden field can be write-only: you set it, you just don't read it back.
Design rationale: planning/gaps3.md #38 and planning/building/kikosi.md #1.
See also
- TypeScript types —
umbral typegen, if you want only the row types and not the client. - Exposure — which models and fields the REST layer serves.
- Filtering & search — the query params the client wraps.