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 reads schema.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:
See the RPC transport guide for the full design. The CLI invocation is identical either way — only the schema changes:

Regenerating after schema changes

The generated package is build output, not hand-edited source — treat it the same way you’d treat a dist/ 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 ships package.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 REST list/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:
That’s correct for a consumer who sometimes requests partial objects, but it’s dead weight for a consumer whose runtime never does — every read of 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:
With the flag, the same Widget (id Int @id, name String, weight Int? in the schema) becomes:
Field presence now tracks the schema’s own nullability instead of wire-projection optionality: 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 the blog.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

Procedure argument interfaces are generated straight from the procedure’s declared parameters — 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

Every model gets 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 with transport 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.
Batch multiple calls into a single round trip with runtime.batch(...) — per-frame errors don’t poison the batch, each response frame reports its own success or failure:
Idempotency keys are a first-class call option on RPC unary calls (propagated as the Idempotency-Key header):
Sequence-returning ops can be consumed as an async iterable via 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
List posts
Create a post
Call a query procedure
Call a mutation procedure
The pattern holds across languages: model accessors are named after the pluralized model (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 throw CratestackRpcError (status + structured RpcErrorBody with a stable code), 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-dir overrides a .j2 file wholesale; there’s no partial-override or “extend the default template” mechanism.

See also