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 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.
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 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.
Adding the SWR layout with --swr
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:
widget.hooks.ts and are imported from that subpath explicitly, never from /swr’s own barrel:
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.
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
queryfilter/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).
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:
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_postsis caught too — not just an already-camelCase spelling. - Suppressed operations are exempt. A name kept out of the generated file by
@@internalor a missingcreaterule cannot collide. get<Model>WithResponseis checked for REST schemas only; the RPC template never emits it.- Only
--swris 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.--refineadds no comparable surface.--tanstackhas the same category of hazard structurally but is not covered by this check.
@@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: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 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
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
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
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(...):
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).
Composable links: @cratestack/*
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:
transport rest schemas, since the REST client has no links/streamLinks chain to plug into.
The CBOR codec
By default, a generatedtransport 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:
jsonRpcCodec explicitly at the call site instead of regenerating:
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.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 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:
/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:
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/swrfile-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 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. - Regenerating an existing RPC client silently switches its wire codec from JSON to CBOR. Since issue #746, the native
@cratestack/cborcodec — andapplication/cboras the defaultContent-Type/Accept— is on by default fortransport rpcschemas. Re-runninggenerate-typescriptagainst an--outdirectory 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’sCodecSetincludesCborCodec(CodecSet::new(CborCodec, JsonCodec)— see RPC transport) — a JSON-onlyCodecSetwill reject the regenerated client with406/415. Otherwise pass--no-native-cborto 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’sruntime.stream(...)example links back to - Transport Architecture