ts-typegen
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.
[]
= "1"
[]
= "1"
// src/user.rs
use Serialize;
use Ts;
// build.rs
cargo build writes:
// 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:
.int64.int128
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. |
new.preset
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:
| 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
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
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:
export type Event =
| { kind: "click"; x: number; y: number }
| { kind: "keyPress"; code: string };
CI
TS_TYPEGEN_CHECK=1
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
= { = "1", = ["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-typegenheader are ever deleted. Hand-written.tsin 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:
// 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.
No build.rs configuration needed — Point is just another scanned type.
This generates:
// 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::Pointand 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 Pointtwice — 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 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