Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Typed frontends (TypeScript & no-build JS)

Your Rust handlers are the API contract. zapline turns the OpenAPI document it already generates into a typed client, and keeps that client current while you work: change a struct, save, and the frontend's type checker points at every call that no longer fits. There is no schema language to maintain, no generator to install, and no npm package in between.

Two flavors come from the same document:

You haveYou getHow
A bundled frontend (React, Vue, Svelte, Solid...)api.ts: interfaces plus a typed createClient()zapline dev with a frontend/ directory, or zapline client -o web/src/api.ts
Plain pages, htmx, <script type="module">api.js: the same client as an ES module with JSDoc typesOpenApi::new(..).client_js() serves /_zapline/api.js

Both use fetch only, so they run in browsers, Node 18+, Deno and Bun, and both pass tsc --strict --exactOptionalPropertyTypes --noUncheckedIndexedAccess.

Live TypeScript with zapline dev

Put your frontend in frontend/ and run zapline dev. After every successful build, before the new binary starts, zapline exports the spec from it and writes frontend/src/api.ts. The file is only rewritten when it changes, so vite, tsc --watch and your editor react exactly once, and dev tells you what moved:

    Building my-app
      Client updated frontend/src/api.ts (+archiveTask ~createTask)
     Running target/debug/my-app (pid 4242)

(+ added, - removed, ~ changed arguments or result.) A Rust change that breaks the frontend now shows up as a red squiggle in the .tsx file within a second of saving the .rs file.

To put the client somewhere else, or to also write the JavaScript module to disk, add a zapline.toml next to Cargo.toml (or the same keys under [package.metadata.zapline.client] in Cargo.toml):

[client]
ts = "web/src/api.ts"     # TypeScript client
js = "static/js/api.js"   # ES module with JSDoc types
# enabled = false         # turn off the frontend/ default

zapline dev --client path/to/api.ts overrides the settings for one run and --no-client turns clients off. zapline client (no -o) writes the configured files once, which is what you want in CI. If the project has a frontend/ directory but the app has no OpenAPI plugin, dev says so once and carries on.

To fail CI when a committed client is stale, compare it with a fresh one in a Rust test:

use zapline::openapi::{codegen, spec_for};

#[test]
fn frontend_client_is_current() {
    let spec = spec_for(my_app::app(), my_app::api());
    let fresh = codegen::typescript(&spec).unwrap().source;
    let committed = std::fs::read_to_string("frontend/src/api.ts").unwrap();
    assert!(committed == fresh, "run `zapline client` and commit frontend/src/api.ts");
}

Using the client

Methods are named after operation ids (the handler name: create_task becomes createTask). Path parameters come first, then the body, then a query object, then per-call options:

import { api, createClient, isValidationError, type Task } from "./api";

const page = await api.listTasks({ status: "todo", limit: 20 }); // query is typed: status is "todo" | "doing" | "done"
const task: Task = await api.getTask(42);
await api.updateTask(42, { status: "done" });
await api.upload(42, { file: input.files![0]!, note: "scan" }); // multipart: a FormData or a plain object
const res = await api.events();                                 // SSE / NDJSON: the raw Response, to stream
const csv: Blob = await api.export();                           // binary bodies resolve to a Blob

// Your own instance: base URL, auth headers, cookies, a custom fetch.
const remote = createClient({
  baseUrl: "https://api.example.com",
  headers: async () => ({ authorization: `Bearer ${await token()}` }),
  credentials: "include",
});
await remote.getTask(1, { signal: AbortSignal.timeout(5000) });

api is a ready same-origin instance. Each method's doc comment carries the route (POST /tasks), the handler's doc comment, and the error responses it documents, so hovering a call shows what the server says about it. Field constraints from #[validate(..)] and #[serde(default)] appear on the interfaces as @minLength, @maximum, @default and so on.

What the generator maps:

In Rust (via the spec)In TypeScript
structs, Option<T> fieldsinterfaces, field?: T | null
unit enumsstring literal unions ("todo" | "doing" | "done"), with each variant's doc
tagged enums (#[serde(tag = "kind")])discriminated unions: { kind: "circle"; radius: number } | { kind: "rect"; ... }
HashMap<String, T>, tuples, Vec<T>Record<string, T>, [A, B], T[]
Path<(String, u32)>, {*rest} catch-allspositional arguments; catch-alls keep their slashes
Query<T> (also optional, also free-form maps)one typed query object
Json<T>, Form<T>, Multipart, String, Bytes bodiesJSON, URL-encoded, FormData, text and binary bodies
Json<T>, String, Bytes, Sse, () / StatusCode resultsT, string, Blob, Response, void

Routes that answer with HTML (htmx pages and fragments) are left out.

Errors, typed

Every non-2xx response throws an ApiError with status, zapline's code and details, the decoded body, and the operation that failed. A 422 that lists field errors (from Valid<T>, or a body field of the wrong type) throws a ValidationError instead, whose fields are keyed by the request body's field names:

try {
  await api.createTask(form);
} catch (e) {
  if (isValidationError(e, "createTask")) {
    e.fields.title?.[0]?.message;  // "length must be between 1 and 80"; `title` autocompletes
    e.messageFor("priority");      // the first message for a field, or undefined
    e.errors;                      // every FieldError { field, code, message, params }
  } else if (e instanceof ApiError && e.status === 404) {
    // ...
  }
}

isValidationError(e, "createTask") only matches errors thrown by that method, which is what lets TypeScript know the field names (they are listed per method in the exported ValidationFields interface). Nested fields use paths such as address.zip and items[0].name.

In a browser, unsafe requests (POST, PUT, PATCH, DELETE) copy zapline's CSRF cookie into the x-csrf-token header, so pages behind Csrf work without extra code. Pass csrf: { cookie, header } if you renamed them, or csrf: false.

No build step: /_zapline/api.js

For server-rendered pages, add .client_js() to the OpenAPI plugin:

App::new()
    .mount((list_tasks, create_task))
    .openapi(OpenApi::new("Tasks", "1.0.0").client_js())

The app then serves /_zapline/api.js, generated once at startup from the same spec (nothing per request, and nothing at all without .client_js()). It is public like /openapi.json, even behind Authenticate::required(). Use it from any page:

<script type="module">
  import { api, isValidationError } from "/_zapline/api.js";

  const form = document.querySelector("#new-task");
  const title = form.querySelector("[name=title]");
  title.addEventListener("input", () => title.setCustomValidity(""));
  form.addEventListener("submit", async (ev) => {
    ev.preventDefault();
    try {
      const task = await api.createTask({ title: title.value });
      location.hash = `task-${task.id}`;
    } catch (e) {
      if (isValidationError(e, "createTask")) {
        title.setCustomValidity(e.messageFor("title") ?? "");
        form.reportValidity();
      }
    }
  });
</script>

The module carries the same types as JSDoc (@typedef, @param, @returns) and starts with // @ts-check. Editors read JSDoc from files on disk, so for autocomplete in your own .js files also write a copy next to them with js = "static/js/api.js" in zapline.toml (or zapline client -o static/js/api.js) and import that path. With // @ts-check at the top of your script, VS Code then flags a wrong field name or a missing argument just as it would in TypeScript.

Limitations

  • Error responses are typed as ApiError / ValidationError; a handler's own error details stay unknown.
  • Only the lowest documented 2xx response is typed. A handler that answers 200 or 201 with different bodies needs an explicit .response::<T>(..) per status and gets the first.
  • Streaming responses (SSE, NDJSON) resolve to the raw Response; for SSE in a browser, new EventSource(url) is usually simpler.
  • Editors cannot see a module that only exists at a URL: /_zapline/api.js gives a page the client at runtime, and the on-disk copy gives your editor the types.