# ts-typegen
[](https://crates.io/crates/ts-typegen)
[](https://docs.rs/ts-typegen)
[](LICENSE)
Generate TypeScript types from Rust structs and enums, at build time.
One `#[derive(Ts)]` and a three-line `build.rs` turn your Rust types into
`.ts` interfaces and unions that match what `serde_json` actually puts on the
wire — every `rename_all`, `skip`, `flatten`, `transparent`, and tagged-enum
representation included. Plain `cargo build` keeps them in sync; `TS_TYPEGEN_CHECK=1`
fails CI when they drift.
```toml
[dependencies]
ts-typegen = "1"
[build-dependencies]
ts-typegen-build = "1"
```
```rust
// src/user.rs
use serde::Serialize;
use ts_typegen::Ts;
#[derive(Serialize, Ts)]
#[serde(rename_all = "camelCase")]
pub struct User {
pub user_id: u64,
pub name: String,
#[serde(skip)]
pub secret: String,
}
```
```rust
// build.rs
fn main() -> Result<(), Box<dyn std::error::Error>> {
ts_typegen_build::Config::new()
.output("bindings")
.run()
}
```
`cargo build` writes:
```ts
// bindings/User.ts
// @generated by ts-typegen -- DO NOT EDIT
export interface User {
userId: number;
name: string;
}
```
plus a `bindings/index.ts` barrel re-exporting every type.
## How it works
The `Ts` derive expands to nothing. Generation happens in your build script,
which parses `src/**/*.rs` with `syn` and emits one `.ts` file per type.
This is deliberate: a build script runs *before* its own crate compiles, so it
can never see your Rust types — only your source text. That rules out the
trait-based approach ts-rs uses, and buys generation on plain `cargo build`
instead of `cargo test`, project-wide configuration, and automatic cleanup of
bindings whose Rust type is gone.
The tradeoff: there is no type resolution. `type Id = u64` aliases and types
from crates you don't scan are invisible, and must be handled with
`#[ts(type = "...")]` or `.map_type(...)`. Unresolvable types are build errors,
never silent `unknown`.
## Configuration
| `.scan(path)` | `"src"` | Directory to scan. The first call replaces the default; later calls append. |
| `.output(path)` | `"bindings"` | Where `.ts` files are written. |
| `.index(bool)` / `.index_name(s)` | `true` / `"index.ts"` | Barrel file. |
| `.int64(BigInt)` | `Number` | How `i64`, `u64`, `isize`, `usize` render: `Number`, `Bigint`, or `Str`. |
| `.int128(BigInt)` | `Number` | Same, for `i128` and `u128`. |
| `.bigint(BigInt)` | `Number` | Shorthand setting both of the above. |
| `.bytes(Bytes)` | `NumberArray` | How `Vec<u8>` / `[u8; N]` render. |
| `.unknown_type(UnknownStyle)` | `Unknown` | `unknown` or `any` for opaque values like `serde_json::Value`. |
| `.header(s)` | — | Lines inserted after the marker in every file, e.g. `/* eslint-disable */`. |
| `.optional_style(OptionalStyle)` | `Nullable` | `T \| null`, `?: T`, or both. |
| `.map_type(rust, ts)` | — | Override a type by path, matched on the final segment. |
| `.preset(Preset)` | — | Built-in mappings for `uuid`, `chrono`, `time`, `url`, decimals, `bytes`. |
| `.struct_style(StructStyle)` | `Interface` | `interface` vs `type` for named-field structs. |
| `.import_extension(ext)` | `""` | Append e.g. `".js"` for ESM under `moduleResolution: node16`. |
| `.always_run(bool)` | `false` | Generate even when built as a dependency. |
Integer widths are configured separately because the risk differs sharply.
`i64` loses precision only above 2^53, which many payloads never reach; `i128`
and `u128` cannot be represented by a JavaScript number at all, and `serde_json`
writes them at full precision -- `340282366920938463463374607431768211455` -- so
`JSON.parse` mangles them every time. A common setting is:
```rust
.int64(BigInt::Number).int128(BigInt::Bigint)
```
The defaults stay `Number` throughout because that is what `serde_json` actually
puts on the wire, and `JSON.parse` really does hand you a `number`. Declaring
`bigint` while parsing with `JSON.parse` would be a type that lies about the
runtime value. Change it when your decoder genuinely produces BigInts — a
reviver, `json-bigint`, msgpack — not merely because the Rust type is wide.
### Presets
`.preset(Preset)` installs `map_type` entries for a foreign crate in one call.
Every mapping targets `string`, matching how each type actually serialises —
never a structural guess. A later `.map_type(...)` call overrides a preset.
| `Preset::Uuid` | `uuid::Uuid` |
| `Preset::Chrono` | `chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, Duration}` |
| `Preset::Time` | `time::{OffsetDateTime, PrimitiveDateTime, Date, Time}` |
| `Preset::Url` | `url::Url` |
| `Preset::Decimal` | `rust_decimal::Decimal`, `bigdecimal::BigDecimal` |
| `Preset::Bytes` | `bytes::Bytes` |
| `Preset::Common` | Every preset above, unioned together. |
```rust
ts_typegen_build::Config::new().preset(ts_typegen_build::Preset::Common)
```
Matching is by the type's final path segment (like `map_type`), so this
applies whether a field writes `uuid::Uuid` or a bare `Uuid` brought in by
`use`.
`Str` changes only the binding, never what serde writes: use it when the Rust
side already serializes those fields as strings.
### Generic foreign types
`.map_type` forwards generic arguments instead of discarding them: matching
`mycrate::Wrapper<T>` against `.map_type("mycrate::Wrapper", "TsWrapper")`
renders `TsWrapper<T>`, with `T` lowered like anywhere else — a primitive, a
scanned type (which still gets imported), or a param of the enclosing
declaration. Write the target name bare, without its own `<...>`; the
argument list is appended for you.
## Attributes
`#[ts(...)]` on a type, field, or variant:
| `rename = "..."` | type, field, variant | Override the emitted name. |
| `type = "..."` (or `as`) | field | Verbatim TypeScript; skips type lowering entirely. |
| `skip` | field, variant | Omit from the output. |
| `optional` | field | Force `?`, whatever serde says. |
| `readonly` | type, field | Emit `readonly`. On a type it applies to every field. |
| `inline` | field | Splice the referenced type's shape in place of importing it. |
`#[serde(...)]` is read too — `rename`, `rename_all`, `rename_all_fields`,
`skip`, `skip_serializing`, `skip_serializing_if`, `flatten`, `transparent`,
`tag`, `content`, `untagged` — including serde's nested
`rename(serialize = "...", deserialize = "...")` form, where only the
serialize arm is used, since that is the shape being emitted. `#[ts(...)]`
wins wherever both apply.
Two serde attributes are deliberately ignored, because neither changes what
serialization produces: `default` and `alias` affect deserialization only. Use
`#[ts(optional)]` if you want a defaulted field marked optional anyway.
`#[serde(with = "...")]` and `#[serde(serialize_with = "...")]` hand the wire
format to code this crate cannot inspect, so the Rust type no longer describes
the JSON. That is a build error naming the field, not a guess:
```
warning: ts-typegen: src/user.rs:75 field `dur` uses serde `with`/`serialize_with`,
so its wire type cannot be derived from its Rust type
help: give the shape explicitly with #[ts(type = "...")]
```
### Name collisions
Two Rust fields can land on the same JSON key — `type_` and `r#type` both
camelCase to `type`. serde permits this and emits duplicate keys; a TypeScript
object type with a repeated key does not compile, so the binding collapses them
into a union and warns:
```
warning: ts-typegen: src/user.rs:75 fields `type_` and `type` both map to the
TypeScript name `type`
help: the binding uses a union of their types; rename one with
#[ts(rename = "...")] to be explicit
```
```ts
export interface Clash {
```
Fields of the same type collapse without a redundant `T | T`. This is a warning
rather than an error because it is rare and the output stays valid — but the
underlying JSON is ambiguous, so renaming one field is the real fix.
### readonly and inline
```rust
#[derive(Serialize, Ts)]
#[ts(readonly)]
#[serde(rename_all = "camelCase")]
pub struct AuditEntry {
pub actor_id: u64,
#[ts(inline)]
pub window: Span,
}
```
```ts
export interface AuditEntry {
readonly actorId: number;
readonly window: { startMs: number; endMs: number };
}
```
Inlining is per-use: `Span` still gets its own file for anyone importing it
normally. Enums cannot be inlined — they render as tagged unions, not object
shapes — and asking for it is a diagnostic rather than a wrong shape.
All four serde enum representations are supported, and the output matches what
`serde_json` actually puts on the wire:
```rust
#[derive(Serialize, Ts)]
#[serde(tag = "kind", rename_all = "camelCase")]
enum Event {
Click { x: i32, y: i32 },
KeyPress { code: String },
}
```
```ts
export type Event =
| { kind: "click"; x: number; y: number }
| { kind: "keyPress"; code: string };
```
## CI
```sh
TS_TYPEGEN_CHECK=1 cargo build
```
Generates in memory, diffs against disk, and fails with the diff instead of
writing. Commit your bindings and this keeps them from drifting.
## Strict mode
```toml
ts-typegen = { version = "1", features = ["strict"] }
```
The derive additionally emits a zero-cost marker assertion per field, so a type
with no mapping fails at `rustc`, pointing at the field, instead of surfacing
later in `tsc`. `#[ts(type = "...")]` suppresses the assertion for that field.
## Safety
Writing into your source tree from a build script is unusual, so:
- The output directory may not overlap a scan root — rejected before anything
is written. This also prevents an infinite rebuild loop.
- Files are compared before writing, so an unchanged build leaves your git tree
clean and mtimes untouched.
- Only files carrying the `@generated by ts-typegen` header are ever deleted.
Hand-written `.ts` in the same directory is never touched.
- Generation is skipped when the crate is compiled as somebody else's
dependency from the registry or a git checkout, so consumers never attempt to
write into a read-only cache. Override with `.always_run(true)`.
## Foreign types you don't own
`.map_type()` and `#[ts(type = "...")]` both hand-write a TS string, which is
fine for an opaque scalar (a timestamp, a decimal) but doesn't scale to a
*structured* foreign type used in several places: every field pays the string
again, and nothing catches them drifting out of sync when the shape changes.
Reference resolution is by name against the registry, not by where a
declaration came from (`render.rs` matches a field's `Ty::Ref` against
whatever `#[derive(Ts)]` type has that name, regardless of module or crate).
So a small local struct that only exists to describe the wire shape — a
"shadow" of the foreign type — gets picked up by every field that names it,
with a real import, for the price of writing it once:
```rust
// A stand-in for geo::Point, a type from a crate you don't own. Give it the
// same name serde would see on the wire (or use #[ts(rename = "...")] to
// decouple it), and every field of type `geo::Point` resolves to it.
#[derive(Serialize, Ts)]
pub struct Point {
pub lat: f64,
pub lng: f64,
}
#[derive(Serialize, Ts)]
pub struct Place {
pub name: String,
pub location: geo::Point,
}
```
No `build.rs` configuration needed — `Point` is just another scanned type.
This generates:
```ts
// Place.ts
import type { Point } from "./Point";
export interface Place {
name: string;
location: Point;
}
```
Matching is purely by name, which cuts both ways:
- If `geo::Point` and your shadow struct's fields ever drift apart, nothing
catches it — this is exactly as trustworthy as the shadow struct is
accurate, same as `#[ts(type = "...")]`.
- If a *real*, unrelated local type also happens to be named `Point`, that's
a genuine ambiguity — `#[derive(Ts)] pub struct Point` twice — and the
scanner already refuses to guess: it's the same "duplicate TypeScript name"
build error two of your own types get, naming both locations. Rename one
with `#[ts(rename = "...")]` to disambiguate.
- Reserve this for a type that's structured and reused; for a single opaque
field, `#[ts(type = "...")]` inline is less machinery.
## Example project
[`fixtures/demo`](fixtures/demo) is a complete crate exercising most of the
above: renamed and skipped fields, all four enum representations, generics,
`transparent`, `readonly`, `inline`, per-variant `rename_all`, a `Preset`-backed
foreign type (`uuid::Uuid`), and byte arrays. Its `build.rs` shows a real
`Config`, and its committed `bindings/` directory shows the output. It also
doubles as this crate's end-to-end test: `cargo test` in the workspace runs
`cargo build` against it and, when `npx` is available, type-checks the result
with `tsc --strict`.
## Not supported
Const generics, trait objects, unions, and languages other than TypeScript.
Type aliases and unscanned foreign types need an explicit mapping.
## License
MIT