ts-typegen 1.0.0

Generate TypeScript types and interfaces from Rust structs and enums at build time, matching what serde puts on the wire
Documentation
# ts-typegen

[![crates.io](https://img.shields.io/crates/v/ts-typegen.svg)](https://crates.io/crates/ts-typegen)
[![docs.rs](https://docs.rs/ts-typegen/badge.svg)](https://docs.rs/ts-typegen)
[![license](https://img.shields.io/crates/l/ts-typegen.svg)](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

| Method | Default | Purpose |
|---|---|---|
| `.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 | Covers |
|---|---|
| `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.

## Attributes

`#[ts(...)]` on a type, field, or variant:

| Attribute | Where | Effect |
|---|---|---|
| `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 {
  type: number | string;
}
```

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