This version is in beta. Some features may change before release.

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.

Code
bash
cargo run -- gen-client --out web/src/api/

That writes two files:

  • client.jsthe 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, a Filters/Ordering/Create/Update per model, the list envelope matching your paginator, and the class declarations.
Info

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.

Code
html
<!-- 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.

Code
ts
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 typed
Your moduleResolutionImport as
bundler (Vite, Next, esbuild)"./api/client"
node (webpack, CRA, older tsconfig)"./api/client"
nodenext (native ESM)"./api/client.js"
Info

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.

Code
rust
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.

Code
bash
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.

Code
ts
import { Umbral, type Post } from "./api/client";
 
const api = new Umbral("https://api.example.com");
const post = await api.get("post", 42); // typed: Post

Keep it in sync

Re-run after any model or REST-resource change, and add `--check` to CI (below) so a stale client can't merge.
Warning
The generated files carry a `// DO NOT EDIT` header — never hand-edit them; your changes are lost on the next generate. Extend the client by wrapping it, not by editing it.

Using it

Code
ts
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); // Post

What 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:

Code
ts
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, partial
await 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_add timestamps, #[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 a slug or a username; 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 typenumber 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:

Code
ts
const api = new Umbral("https://api.example.com");
 
const who = await api.auth.me(); // AuthUser | null — null means signed out
const { 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 anonymous
  • me() resolves null when 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 no auth namespace at all.
Warning

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:

Code
ts
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.

Code
ts
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(...), an updated event 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:
Code
ts
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.

Code
ts
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 Filters type.
  • Your configured base path (/api, /v1, …) is baked into the request URLs.
  • The list envelope matches your paginator — {results, count} for none, plus total_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:

Code
ts
new Umbral(url, { token }); // Authorization: <prefix> <token>
new Umbral(url, { token, tokenPrefix: "Token" }); // override the prefix
new Umbral(url, { apiKey }); // <declared-header>: <apiKey>
new Umbral(url, { apiKey, apiKeyHeader: "x-key" }); // override the header
new Umbral(url, { getAuthHeaders: async () => ({ Authorization: `Bearer ${await fresh()}` }) });
  • A http / bearer-style scheme sets the Authorization prefix from the scheme's scheme field — bearerBearer, tokenToken. That's the default for token; override per-request with tokenPrefix.
  • An apiKey / header scheme bakes its declared name as the default header for apiKey (X-API-Key, X-Umbral-Api-Key, whatever you declared); override with apiKeyHeader.
  • An apiKey / cookie (session) scheme makes the client send credentials by default (fetch credentials: "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:

Code
bash
cargo run -- gen-client --out web/src/api/ --check
Info
Commit the generated files. Your frontend build shouldn't need a Rust toolchain to typecheck, and a reviewer should see the client change in the same diff as the model change that caused it.

Realtime

The same client subscribes to live model-change events. client.on(table, handlers, { group }) routes created / updated / deleted to typed handlers:

Code
ts
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" });
 
// later
sub.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:

Code
rust
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 an EventSource per 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-op under CSP).
Info

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 internal cost) 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

resttypescriptclientsdkcodegenfrontend