ts-rs: one TS trait, a derive macro, and bindings written by cargo test
Generate TypeScript bindings from Rust types
At a glance
- What is it?
- ts-rs turns Rust structs and enums into TypeScript declarations through a single TS trait and a derive macro, exporting them from a generated test run. It suits Rust backends that must hand typed shapes to a TypeScript frontend, and it fails quietly where serde and TS disagree.
- Who is it for?
- Adopt ts-rs when a Rust service and a TypeScript client already share your domain types and you are willing to run the export test in CI. Skip it if your Rust types are built through dynamic JSON, or if you cannot accept a compile-time trait bound on every type crossing the boundary.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 21 days ago.
- What is it written in?
- Mainly Rust, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.
Editorial analysis
The problem ts-rs solves is a second definition of the same shape
A Rust web backend and a TypeScript frontend have to agree on the shape of every payload that crosses the boundary. Hand-writing the TypeScript side creates a second definition that drifts the first time someone adds a field in Rust and forgets the mirror file. ts-rs removes the second definition. It generates the TypeScript declarations from the Rust types themselves, so the frontend type is derived from the same struct the backend serializes.
The stated audience is anyone building a web application in Rust where data structures are shared between backend and frontend. The README also names webassembly as a second case, where the same reasoning applies between a compiled module and the JavaScript that calls it. The scope is types only. ts-rs does not generate API clients, request functions, validation code or serialization logic on the TypeScript side, so it is one component of a contract pipeline rather than the whole pipeline.
Everything routes through a single trait called TS
The mechanism is deliberately narrow. ts-rs exposes one trait, `TS`, and a derive macro implements it for your types. Once a type implements `TS`, the trait gives you the TypeScript bindings: `TS::export_all`, `TS::export`, and `TS::export_to_string` exist so the output can be produced without going through a test at all.
The file output path takes a different route. When you annotate a type with `#[ts(export)]`, the macro generates a test that writes the bindings for that type to disk when it runs. The README recommends doing the export in tests, which has a pleasant side effect: the export is checked by the same `cargo test` you already run, so a type that fails to produce bindings fails the build rather than producing a stale file nobody notices.
The repository is a Cargo workspace with members `macros`, `ts-rs` and `example`, plus an `e2e` directory. The split matters if you fork or vendor it: the derive lives in the macros crate, the runtime in ts-rs, and both need to move together when you pin a version.
From a struct annotation to a bindings directory
Add the dependency to Cargo.toml, deriving `TS` on the struct and marking it for export. The `export` attribute is what causes the generated test to appear:
[dependencies]
ts-rs = "12.0"#[derive(ts_rs::TS)]
#[ts(export)]
struct User {
user_id: i32,
first_name: String,
last_name: String,
}Running `cargo test` or `cargo test export_bindings` writes one TypeScript file per exported type, in this case `bindings/User.ts`, holding a single exported type alias:
export type User = { user_id: number, first_name: string, last_name: string, };Note what the mapping does and does not do. A Rust `i32` becomes the TypeScript `number`, and a `String` becomes `string`, but the field names are preserved exactly as written in Rust, snake_case included. Renaming is a serde concern, handled below, not a default transformation.
Three environment variables decide where and how bindings land
The generated test reads its configuration from the environment, which means the export location can differ per machine and per CI job. `TS_RS_EXPORT_DIR` sets the base directory for exported bindings and defaults to `./bindings`. `TS_RS_IMPORT_EXTENSION` sets the file extension used in `import` statements and defaults to none, which matters when you export to multiple files and want imports written a particular way. `TS_RS_LARGE_INT` picks the binding for `i64`, `u64`, `i128` and `u128`, defaulting to `bigint`.
The README recommends persisting this in Cargo's own config file so it survives shell restarts and applies to every contributor. The README recommends `relative = true` in its persistent configuration example:
# <project-root>/.cargo/config.toml
[env]
TS_RS_EXPORT_DIR = { value = "bindings", relative = true }
TS_RS_LARGE_INT = "number"That last line is a real decision, not a formality. Switching the wide integers from `bigint` to `number` will silently lose precision in JavaScript for values beyond the safe integer range, so keep the default unless you have measured the range your payloads actually use.
serde attributes are translated, and the gaps are documented
With the `serde-compat` feature, enabled by default, ts-rs reads serde attributes on structs and enums so the generated type matches what serde will actually serialize. The supported set is `rename`, `rename-all`, `rename-all-fields`, `tag`, `content`, `untagged`, `skip`, `skip_serializing`, `skip_serializing_if`, `flatten` and `default`.
Three caveats come with that list. `skip_serializing` and `skip_serializing_if` only take effect alongside `#[serde(default)]`, so that the generated type stays correct in both directions. `skip_deserializing` is ignored entirely; to drop a field from the generated type without changing serde behaviour you need `#[ts(skip)]`. And when ts-rs meets an attribute it does not support, it emits a warning at build time unless you enable the `no-serde-warnings` feature.
Silencing those warnings is a choice with a cost. A type can drift from its wire format and the build will stay quiet. Keeping the default and treating unknown-attribute warnings as errors in CI is the more useful setup, even though it is noisier.
Optional cargo features pull in implementations for the crates you already use
Types from other crates only work if something implements `TS` for them, and that is what the feature table is for. `serde-json-impl` covers `serde_json`, `chrono-impl` covers `chrono`, `bigdecimal-impl` covers `bigdecimal`, `url-impl` covers `url`, `uuid-impl` covers `uuid`, `bson-uuid-impl` covers `bson::oid::ObjectId` and `bson::uuid`, `bytes-impl` covers `bytes`, `indexmap-impl` covers `indexmap` and `ordered-float-impl` covers `ordered_float`. Separately, `format` enables formatting of the generated TypeScript, and the README notes this unfortunately adds quite a few dependencies.
For a type that implements `TS` nowhere, there are three documented escapes: annotate the field with `#[ts(as = "..")]` to name an existing type, use `#[ts(type = "..")]` to state the TypeScript type directly, or open a pull request. The third is the slow one, which is why the first two exist. Every field you hand-annotate is a place where Rust and TypeScript can drift without any compiler or test noticing.
Where ts-rs is the wrong tool
The failure mode is structural, not a bug. ts-rs binds types through a trait, so it can only describe data whose shape is known at compile time. Schemas fetched at runtime or payloads assembled through dynamic data structures may not have a fixed Rust type to derive from. A handwritten `#[ts(type = "..")]` remains possible, but can duplicate the contract.
There is a second limit around generics and unions. The feature list promises union declarations from Rust enums and support for generic types, but the generated file is a plain type alias, with no generics parameterised in the output shown. Complex recursive or deeply generic type graphs are where you should check the e2e directory in the repository before committing to the approach.
Against a schema-first alternative, the difference is direction of authority. A schema-first workflow treats the schema as the source of truth. ts-rs runs the other way: Rust is the source of truth and the TypeScript follows. That is cheaper when Rust owns the domain and less suitable when the contract is negotiated across teams or languages.
Version 12 is a major bump, so read the changelog before upgrading
Three releases are recent enough to describe the upgrade path: v11.0.0 on 2025-06-03, v11.1.0 on 2025-10-14 and v12.0.0 on 2026-01-31. The last push to the repository was on 2026-10-02, which is months after the v12.0.0 tag and means main carries changes that no published version contains yet. Pinning `ts-rs = "12.0"` as the README shows keeps you on the tagged behaviour rather than on main.
Because 11 to 12 crosses a major version, a jump is not a semver no-op, and the repository keeps a CHANGELOG.md alongside CONTRIBUTING.md. Read it before the upgrade rather than after the CI failure. The MIT license permits commercial use with its notice obligations. Since bindings are generated artifacts, your policy on committing generated TypeScript to source control still matters.
Editorial conclusion
Adopt ts-rs when a Rust service and a TypeScript client already share your domain types and you are willing to run the export test in CI. Skip it if your Rust types are built through dynamic JSON, or if you cannot accept a compile-time trait bound on every type crossing the boundary. Verify first that every type in your export set implements TS, that your serde attributes fall inside the supported list, and that the TS_RS_LARGE_INT choice matches what your JSON parser can actually parse.
Frequently asked questions
How do I generate TypeScript types from Rust with ts-rs?
Add `ts-rs = "12.0"` to your dependencies, derive `TS` on the struct or enum, and add `#[ts(export)]`. Running `cargo test` or `cargo test export_bindings` then writes the declaration to `bindings/`, for example `bindings/User.ts`.
Where does ts-rs put the generated bindings, and can I change the directory?
The base directory defaults to `./bindings` and is set through the `TS_RS_EXPORT_DIR` environment variable. The README recommends persisting it in `.cargo/config.toml` under `[env]` with `relative = true` so the path resolves against the project root.
What does ts-rs do with Rust 64-bit integers in TypeScript?
The `TS_RS_LARGE_INT` variable selects the binding for `i64`, `u64`, `i128` and `u128`, and it defaults to `bigint`. Setting it to `number` is possible, as the README example shows, but values beyond the JavaScript safe integer range will lose precision.
How does ts-rs handle serde attributes?
With the `serde-compat` feature, enabled by default, it parses `rename`, `rename-all`, `rename-all-fields`, `tag`, `content`, `untagged`, `skip`, `skip_serializing`, `skip_serializing_if`, `flatten` and `default`. `skip_deserializing` is ignored, so use `#[ts(skip)]` to exclude a field from the generated type.
Can I export ts-rs bindings without running tests?
Yes. `TS::export_all`, `TS::export` and `TS::export_to_string` produce bindings programmatically, which is the route to use in a build script or a separate binary when the test-driven export does not fit your pipeline.
What is the license of ts-rs?
The repository is MIT licensed, with a LICENSE file at the top level. The published crate version to match is 12.0, tagged v12.0.0 on 2026-01-31.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/aleph-alpha-ts-rs)Was this useful?