ts-typegen 1.2.0

Generate TypeScript types and interfaces from Rust structs and enums at build time, matching what serde puts on the wire
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
# 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.

### Generic foreign types

A `map_type` target can carry a matched type's generic arguments through with
`{0}`, `{1}`, ... placeholders:

```rust
.map_type("mycrate::Wrapper", "TsWrapper<{0}>")  // constructor: Wrapper<T> -> TsWrapper<T>
.map_type("chrono::DateTime", "string")           // terminal: the argument is dropped
.map_type("dto::Patch", "{0} | null")             // shaped alias: no wrapper name at all
```

A placeholder is substituted with its argument lowered like anywhere else — a
primitive, a scanned type (which still gets imported), or a param of the
enclosing declaration. A target with **no** placeholder drops the argument
entirely and unconditionally, before it can reach an import or an "unknown
type" diagnostic: `chrono::DateTime<Utc>` mapped to `"string"` never needs
`Utc` to resolve as anything. This is what makes every built-in
[preset](#presets) safe to use with the generic arguments their real types
always carry.

Mapping a generic type to a target with no placeholder and no arguments at
the use site is silent — nothing was dropped. But mapping one *with* an
argument at the use site to a target that isn't a bare TS keyword
(`string`, `number`, `boolean`, `unknown`, `any`, `never`, `null`) and doesn't
already spell out its own `<...>` is usually a forgotten `{0}`, so it warns:

```
warning: ts-typegen: src/user.rs:12 map_type target `TsWrapper` for `Wrapper`
  has no `{0}` placeholder, but `Wrapper` was used here with 1 generic argument
  help: add `{0}` to the mapping to forward it, e.g.
        .map_type("...::Wrapper", "TsWrapper<{0}>") -- or ignore this if
        dropping it is intentional, the way string-valued presets do
```

### Type aliases

`pub type Timestamp = chrono::DateTime<chrono::Utc>;` resolves on its own —
the scanner already parses the file it's declared in, so a plain alias needs
no `build.rs` entry at all. A field typed `Timestamp` lowers exactly as if it
had been written out in full, terminal `map_type` mappings included:

```rust
pub type Timestamp = chrono::DateTime<chrono::Utc>;

#[derive(Serialize, Ts)]
pub struct Event {
    pub at: Timestamp,
}
```

```ts
export interface Event {
  at: string;
}
```

Generic aliases work too — `pub type Boxed<T> = Vec<T>;` instantiates `T` at
each use site, the same substitution a scanned generic struct gets. An alias
can reference another alias declared anywhere in the scan, in either order;
a cycle (`type A = B; type B = A;`) is a build error naming both, not a hang.

## 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.
(For a scalar behind your *own* alias rather than a bare foreign path, a
[type alias](#type-aliases) is usually less machinery than either.)

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