TypeScript client generation

cratestack generate-typescript renders a complete, publishable TypeScript package from a parsed .cstack schema: typed models, a fetch-based client, and data-fetching 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, its two output layouts (default and swr), what each contains, how to use them against both transport styles, the optional @cratestack/* package family for RPC clients, 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:
TypeScriptGeneratorConfig implements Default, so the struct-update syntax above only needs to name the fields you’re overriding — full_selection, native_cbor, schema_sha256 and the layout flags (swr, refine, tanstack) all take sane defaults otherwise. 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.

Output layout

Every generated package ships the layout below. --swr adds a second, file-per-model layout beside it under src/swr/ — it does not replace anything, and the files described here are identical whether or not you pass it.
This replaced a --preset <default|swr> flag, where picking swr meant giving up the default layout. Teams who wanted both were running the generator twice into two directories and depending on two packages. One run now produces both. If you have --preset swr in a script, drop it and pass --swr; if you have --preset default, just remove it.

The default 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.

Adding the SWR layout with --swr

Everything from the default layout is still there. In addition, under src/swr/, you get one module per model plus a sibling hooks module — reachable by a consumer as @example/board-client/swr (plus /swr/models/*, /swr/procedures, /swr/procedures.hooks) through exports subpaths the flag adds to the generated package.json: Given a Widget model, listWidgets/getWidget/createWidget/updateWidget/deleteWidget land in src/swr/models/widget.ts:
The hooks live in the sibling widget.hooks.ts and are imported from that subpath explicitly, never from /swr’s own barrel:
Why hooks are a separate file, not exports at the bottom of widget.ts. ES modules resolve every top-level static import eagerly, the moment the module loads — regardless of which export the importer actually asked for. If useWidgets lived in widget.ts alongside listWidgets, importing listWidgets alone from a script, a server action, or a test would still pull in import useSWR from "swr" (and transitively React) at module-load time, even though nothing in that code path touches React. Splitting the hooks into widget.hooks.ts — and leaving that file out of src/index.ts’s barrel export — means a consumer who wants only the plain functions never resolves swr/react at all. Install swr and react as peer dependencies only if you import a .hooks module.
Build your shared runtime from /swr if you use the SWR hooks. The default layout’s CratestackRuntime and the one under /swr are structurally identical but nominally distinct TypeScript classes (they carry private fields), so passing a root-imported runtime into a /swr hook is a type error. This could not happen under the old --preset swr, where a package only ever contained one layout — it is new with --swr, and it is the first thing to check if tsc complains about two types that look the same.
Cache keys and invalidation. Every hook builds its key exclusively through swrKeys (src/swr/swr-keys.ts) — never a hand-written literal — nested under each model’s/procedure’s own schema-unique route, so two differently-named operations can never collide on a key. Mutation hooks invalidate on a fixed rule, applied identically for every model:
  • create invalidates the model’s list — every cached list, regardless of query filter/pagination.
  • update invalidates the list and the mutated entity’s own detail (both refetch on next read).
  • delete invalidates the list and drops the deleted entity’s detail from the cache outright (revalidate: false — nothing left to refetch).
This rule isn’t configurable per call. If you need different invalidation, call mutate/swrKeys directly instead of the generated hook. Procedure hooks never invalidate anything — invalidation is model CRUD’s job. Type ownership. A type referenced by exactly one model is defined inline in that model’s own file. A type referenced by two or more models, referenced only by a procedure, or declared but unused, lives in src/swr/models/shared.ts and is imported by its consumers instead. A relation field that references another model’s own type (e.g. author: User on a Post) is always imported with import type, never a value import, so two models that reference each other can only ever produce a type-only import cycle — which TypeScript tolerates — never a runtime one. A procedure may not share a name with a generated model function. --swr is the only layout that exports a model’s CRUD operations as top-level free functions (listPosts, getPost, createPost, …, derived from the model name), and its src/swr/index.ts barrel re-exports both ./models/<model>.js and ./procedures.js. A schema with model Post and procedure listPosts therefore puts two bindings of the same name into one barrel:
Generation used to exit 0 and leave that for the consumer’s own build to discover. Since 0.8.14, generate-typescript --swr refuses the schema up front, before writing any file, naming the procedure, the model, the operation and the shared identifier. Details:
  • The check normalizes to camelCase, so procedure list_posts is caught too — not just an already-camelCase spelling.
  • Suppressed operations are exempt. A name kept out of the generated file by @@internal or a missing create rule cannot collide.
  • get<Model>WithResponse is checked for REST schemas only; the RPC template never emits it.
  • Only --swr is affected. The default layout exposes model operations as methods on per-model classes (client.post.list(...)), so there is nothing for a top-level procedure function to collide with. --refine adds no comparable surface. --tanstack has the same category of hazard structurally but is not covered by this check.
Rename the procedure (or the model) to resolve it. Known gaps. @@paged models are handled correctly: every file that needs it imports Page/PageInfo from src/swr/models/shared.ts, same as the default layout. For transport rpc schemas, --swr’s src/swr/runtime.ts honours the CBOR default and --no-native-cbor identically to the default layout’s src/runtime.ts — since 0.8.14, see The CBOR codec below.

Build the generated package

The generator emits an npm package skeleton, not compiled JS — build it before consuming it:
The generated package.json lists @tanstack/react-query 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. With --swr the manifest additionally lists swr and react, needed only if you import a .hooks module — importing a model’s plain functions or src/swr/procedures.ts needs neither. For a transport rpc schema, the manifest also lists @cratestack/cbor under real dependencies (not peerDependencies) — npm install pulls it in automatically, no opt-in step required — because the RPC runtime resolves it by default (see The CBOR codec below). Pass --no-native-cbor and that dependency is absent instead. REST-transport packages never carry it either way; rest-runtime.ts.j2 has no codec seam.

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

The examples in this section and the next use the default layout’s client-class API. For the per-model function/hook API --swr adds, see Adding the SWR layout with --swr above. 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. Two argument types get dedicated generated shapes rather than a plain scalar mapping: PageInput ({ limit: number | null; offset: number | null; }, hardcoded once per package) and FindMany<Model> (a per-model PostWhere/PostFindMany pair, backed by shared StringFilter/NumberFilter/etc. interfaces) — see Pagination and Search with Filters for the full contract each one generates.

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(...):
When the server picks the negotiated non-streaming codec (CBOR by default — see below), the body is a single array decoded and yielded in one go. When the server picks application/cbor-seq for a genuinely-incremental @stream procedure, the runtime’s own CBOR-sequence boundary scanner decodes and yields each item as it arrives on the wire — never after buffering the whole body first. A response that ends in the mid-stream error sentinel throws CratestackRpcStreamError instead of yielding a final item. See the RPC transport guide’s “Consuming streams” section for the wire-level details and how this compares to the Rust/Flutter/dio client paths. CratestackRpcClientOptions also accepts a links?: RpcLink[] array for composing cross-cutting concerns (logging, retry, auth-refresh, automatic batch coalescing via @cratestack/api) in front of call()/batch() without one override clobbering another — see RPC transport: client middleware for the full design. stream() calls bypass links entirely. 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). CratestackRpcRuntime accepts a links array (unary/batch calls) and a separate streamLinks array (stream() calls) — interceptor chains where each link wraps the next, terminating in the real network call. Passing neither is a true no-op: requests are byte-identical to not having the option at all. Both types (RpcLink, RpcStreamLink, RpcLinkRequest, …) are generated directly into src/links.ts, so a link doesn’t need to import anything from the generated package to be assignable there — TypeScript’s structural typing means any object shaped like RpcLink fits. That structural fit is what the @cratestack/* npm family builds on: twelve small packages (plus a backward-compatible umbrella) that ship ready-made links, alternate transports, codecs, and framework adapters for RPC-transport generated clients, published and installable independently of the generated package itself: See The CBOR codec below for what the generated RPC runtime resolves by default and how to opt back into JSON.
@cratestack/api is a backward-compatible re-export shim over the split, not a thirteenth independent package — its root import stays exactly ts-types + link-batch + link-logger (unchanged from before the split), and everything else added since is a named subpath that pulls in only its own peer dependency:
New projects can install the individual packages directly instead — smaller install, no unused peer dependencies pulled in through the umbrella. Every package in the family is scoped to RPC-transport generated clients; none of it applies to transport rest schemas, since the REST client has no links/streamLinks chain to plug into.

The CBOR codec

By default, a generated transport rpc client resolves @cratestack/cbor’s createCborCodec() (issue #746) — the same umbrella package listed in the table above, auto-selecting @cratestack/cbor-node in Node or @cratestack/cbor-web in the browser. Because both sides are the same Rust CborCodec the server uses, the wire bytes are identical across languages, the same property cratestack_cbor gives the Dart client and cratestack-client-rust gives the Rust one. The default request/response Content-Type/Accept becomes application/cbor accordingly. The constructor stays synchronous even though createCborCodec() is async: the runtime lazily creates and memoizes the codec promise, awaiting it right after buildHeaders() on each call, and a rejected resolution is not memoized — a transient init failure retries on the next call rather than permanently bricking the runtime instance. codec: in CratestackRpcClientOptions still overrides synchronously and skips resolution entirely, exactly as before. Pass --no-native-cbor at generation time to opt back into the pure-TypeScript jsonRpcCodec (application/json) instead — the same escape hatch Dart’s --no-native-cbor provides:
Or keep the native default and pass jsonRpcCodec explicitly at the call site instead of regenerating:
This is no longer a TypeScript-specific asymmetry — Rust (cratestack-client-rust), Dart (cratestack_cbor, issue #563), and TypeScript RPC clients all default to CBOR now. Only REST-transport TypeScript clients are unaffected: rest-runtime.ts.j2 has no codec seam, so a REST client stays JSON-only regardless of this flag.

Platform support

@cratestack/cbor-node’s native N-API binary is vendored for seven platforms as of 0.11.0: @cratestack/cbor-web’s wasm-bindgen build has no platform gap — it runs anywhere a browser or a WASM-capable JS runtime does.
Alpine works now (#850). The failure it fixes was not a fallback to something slower — it was fatal. The generated native.mjs detects musl and looks only at the -musl package names; the -gnu binary sitting next to it is never attempted, so the loader ended at “Cannot find native binding. npm has a bug related to optional dependencies…”, which points at npm rather than at the missing platform.Alpine consumers are not gated on the platform subpackages being bootstrapped on npm: the main @cratestack/cbor-node tarball bundles every .node binary and the loader prefers the bundled file over the subpackage, so a released package initializes on Alpine either way. That bundling is deliberate, not an oversight.The target list in packages/cratestack-cbor-node/package.json’s napi.targets and the CI build matrix are checked against each other by just verify-napi-targets — adding a target in one place without the other fails the build rather than shipping a silently missing platform.
Windows arm64 is why --no-native-cbor still exists for TypeScript. A default-generated RPC client’s dependencies on @cratestack/cbor resolves to @cratestack/cbor-node in Node, which has no prebuild for that target. If your deployment target is Windows arm64, pass --no-native-cbor at generation time and the client falls back to the pure-TypeScript jsonRpcCodec — no native binary required, runs anywhere Node or a browser does.
Regenerating a --swr + transport rpc client changes its wire codec from JSON to CBOR. Until 0.8.14, src/swr/runtime.ts ignored native_cbor entirely and always emitted the jsonRpcCodec fallback, so a --swr package shipped two runtimes that disagreed: src/runtime.ts on application/cbor, src/swr/runtime.ts on application/json. #765 fixed that — both runtimes now resolve createCborCodec() by default, and --no-native-cbor turns both off identically. This is the same breaking change #752 already made for the default layout, now applied to --swr for real: a server on a JSON-only CodecSet will reject a regenerated --swr client with 406/415. Confirm the server accepts CBOR before regenerating, or pass --no-native-cbor. Anyone relying on the previous split behaviour was relying on the bug.

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 only method/procedure names are cased per-language convention — camelCase in TypeScript and Dart, snake_case in Rust (.posts(), get_feed). Struct field names (e.g. authorId, postId) keep the schema’s own original casing verbatim in all three languages, Rust included — there’s no serde rename, so a Rust CreatePostInput literal still reads authorId: 1, not author_id: 1, as the code samples above show.

Computed-field params

A model with a @computed(params: <Type>?) field gets a generated <Model>ComputedParams interface, and the shared query types are generic over it (CratestackFetchQuery<TComputedParams = never>) — so computedParams is fully typed on parameterized models and a compile error on everything else:
This works on both the REST and RPC clients and through the /swr layout, whose cache keys incorporate the params so differently-parameterized reads never collide. The RPC client’s per-model get options bag also carries the projection surface — fields, include, and includeFields — mirroring what CratestackFetchQuery has always given REST get, so a projected, parameterized read is one call:
See Computed Fields for the schema side and the wire format.

Caveats

  • Bundle size on large schemas, default layout only. 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. The /swr file-per-model layout doesn’t have this problem — importing one model’s module never reaches another’s.
  • 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.
  • Regenerating an existing RPC client silently switches its wire codec from JSON to CBOR. Since issue #746, the native @cratestack/cbor codec — and application/cbor as the default Content-Type/Accept — is on by default for transport rpc schemas. Re-running generate-typescript against an --out directory generated before that change (or with an older CLI) upgrades it in place with no warning at generation time. Before regenerating a client that talks to a live server, confirm the server’s CodecSet includes CborCodec (CodecSet::new(CborCodec, JsonCodec) — see RPC transport) — a JSON-only CodecSet will reject the regenerated client with 406/415. Otherwise pass --no-native-cbor to keep the client on JSON.

See also

  • cratestack-client-typescript — crate README, source of the CLI/Rust invocation examples above
  • @cratestack/api — the compat umbrella over the split link/runtime/validator/adapter package family
  • Client Runtime — the Dart/Flutter integration path this guide’s Rust and Dart examples are drawn from
  • RPC transport — full design for transport rpc, including the “Consuming streams” section this guide’s runtime.stream(...) example links back to
  • Transport Architecture