TypeScript client generation
cratestack generate-typescript renders a complete, publishable TypeScript package from a parsed .cstack schema: typed models, a fetch-based client, and TanStack Query hooks. It’s implemented by cratestack-client-typescript and uses the same schema-first approach as the Rust and Dart client generators — there is no OpenAPI/Swagger document in the middle, the .cstack file is the only source of truth.
This guide covers generating the package, what it contains, how to use it against both transport styles, and how the same call looks across TypeScript, Dart, and Rust.
Generate the package
From the CLI
generate-ts works as a shorter alias for the same subcommand. Once the CLI binary is installed, drop the cargo run -p cratestack-cli -- prefix and call cratestack generate-typescript ... directly.
The client class name is derived from
--package-name: non-alphanumeric characters become spaces, the result is PascalCased, and Client is appended. @example/blog-client becomes ExampleBlogClientClient. Pick a package name with that in mind if the resulting class name matters to you.
From Rust
Call the generator directly when you’re wiring codegen into your own build script, a CI step, or Studio-adjacent tooling, instead of shelling out to the CLI:generate_package is pure — it takes a parsed Schema and a config, and returns an in-memory file list. Writing those files to disk is the caller’s job, exactly like handle_generate_typescript does inside the CLI.
Picking REST or RPC
There’s no flag for this. The generator readsschema.transport off the parsed .cstack file and switches templates accordingly — REST is the default, and a schema opts into RPC with the transport rpc directive at the top of the file:
Regenerating after schema changes
The generated package is build output, not hand-edited source — treat it the same way you’d treat adist/ folder. Whenever the .cstack schema changes (or the generator/templates change), re-run the same generate-typescript command against the same --out directory. There’s no incremental/merge step; the generator overwrites the package’s src/ files wholesale.
Build the generated package
The generator emits an npm package skeleton, not compiled JS — build it before consuming it:@tanstack/react-query is listed as a peerDependency, so npm install won’t pull it in on its own — only install it if you’re going to import the generated React Query hooks.
Package layout
Every generated package shipspackage.json, tsconfig.json, README.md, and a src/index.ts barrel that re-exports everything. What’s under src/ beyond that depends on the schema’s transport:
The per-model classes in
client.ts aren’t hardcoded fetch calls duplicated per endpoint — each method is a few lines that delegates into the single shared runtime class, so the actual request/serialization/error-handling logic exists once regardless of how many models the schema declares.
Full selection: fully-required model types
By default, every scalar field on a generated model interface is optional — because a RESTlist/get call can return a partial projection via fields/include, the static type has to allow for any field being absent from the wire response:
widget.id has to be narrowed or non-null-asserted even though the field is always present in practice. Pass --full-selection to opt a generation run out of that:
Widget (id Int @id, name String, weight Int? in the schema) becomes:
id/name are required because the schema declares them non-nullable, and weight stays optional because the schema declares it nullable (Int?) — that part of the contract doesn’t change. Only the plain per-model read interface is affected. Create{Model}Input already derives optionality from schema nullability and is untouched; Update{Model}Input stays entirely optional, since PATCH semantics mean every field is inherently a partial update regardless of this flag.
This is a per-invocation choice, not a schema-level one — deliberately. Whether a given client always fetches full objects or sometimes uses fields/include is a property of how that particular consumer calls the API, not of the schema itself; two client packages generated from the same schema can pick differently. Omitting the flag leaves existing generated output unchanged.
Only use --full-selection for a consumer that truly never sends partial fields/include selection. If that consumer’s runtime later starts using projection, the generated types will silently no longer match what the server can actually omit from the response — there’s no runtime check tying the flag to actual call sites.
Using the REST client
Examples below use theblog.cstack fixture (crates/cratestack-pg/tests/fixtures/blog.cstack): a Post model with full CRUD, a Session model with @@paged, a query procedure getFeed, and a mutation procedure publishPost.
Construct the client
headers accepts a static object or an async function, evaluated on every request — useful for token refresh. Per-call headers merge on top:
CRUD
Session opts into @@paged, so its list returns Page<Session> instead of Session[] — the shape ({ items, pageInfo }) is generated per model based on that schema attribute, not something you opt into on the client.
Procedures
getFeed(limit: Int?) becomes GetFeedArgs { limit?: number | null }; publishPost(args: PublishPostInput) becomes PublishPostArgs { args: PublishPostInput }, mirroring the parameter name declared in the schema.
TanStack Query hooks
use{Model}ListQuery, use{Model}Query, useCreate{Model}Mutation, useUpdate{Model}Mutation, and useDelete{Model}Mutation. Query procedures get use{Procedure}Query; mutation procedures get use{Procedure}Mutation.
Using the RPC client
For a schema withtransport rpc (the widget_rpc.cstack example above), the generated client speaks POST /rpc/{op_id} and POST /rpc/batch instead of per-model REST routes. The per-model API surface looks almost identical to REST — same method names, same accessor pattern — but every call is dispatched by a canonical op ID (model.Widget.list, procedure.echoName, …) rather than a URL path.
runtime.batch(...) — per-frame errors don’t poison the batch, each response frame reports its own success or failure:
Idempotency-Key header):
runtime.stream(...). Today this only decodes JSON-array responses — the default runtime doesn’t yet ship a CBOR-sequence decoder, so a server that picks application/cbor-seq for a streaming call will surface CratestackRpcTransportError until a decoder is wired in.
TanStack Query hooks are generated for RPC schemas too, with the same naming as REST (useWidgetListQuery, useEchoNameQuery/useEchoNameMutation depending on whether the procedure is declared query or mutation).
Side-by-side: TypeScript, Dart, and Rust
All three client generators (cratestack-client-typescript, cratestack-client-dart, cratestack-client-rust) work from the same .cstack schema and land on a deliberately similar shape: a top-level client object, one accessor per model, one procedures namespace. Below is the same four operations against blog.cstack — construct the client, list posts, create a post, call a query procedure, call a mutation procedure — in each language.
Construct the client
posts / blogClient.posts / client.posts()), procedures live under a procedures namespace, and identifiers are cased per-language convention — camelCase in TypeScript and Dart, snake_case in Rust — while the schema’s own field/procedure names stay recognizable in all three.
Caveats
- Bundle size on large schemas. The top-level client class eagerly
news a wrapper instance for every model in its constructor, so a bundler’s tree-shaker can’t drop an unused model’s class if you only import the client — every model’s ~30-line wrapper class is reachable from the one thing you imported. For schemas with a handful of models this is negligible; for schemas with dozens, it’s a fixed cost baked into the client regardless of what you actually call. - No cross-language error type unification yet. REST failures throw
CratestackHttpError(status + response + payload), RPC failures throwCratestackRpcError(status + structuredRpcErrorBodywith a stablecode), Dart and Rust each have their own error shapes. There’s no shared error contract across the generated clients today. - Template overrides are all-or-nothing per file.
--template-diroverrides a.j2file wholesale; there’s no partial-override or “extend the default template” mechanism.
See also
cratestack-client-typescript— crate README, source of the CLI/Rust invocation examples above- Client Runtime — the Dart/Flutter integration path this guide’s Rust and Dart examples are drawn from
- RPC transport — full design for
transport rpc - Transport Architecture