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

ts-typegen

crates.io docs.rs 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.

[dependencies]
ts-typegen = "1"

[build-dependencies]
ts-typegen-build = "1"
// 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,
}
// build.rs
fn main() -> Result<(), Box<dyn std::error::Error>> {
    ts_typegen_build::Config::new()
        .output("bindings")
        .run()
}

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(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.
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:

.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 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:

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

#[derive(Serialize, Ts)]
pub struct Event {
    pub at: Timestamp,
}
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
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

#[derive(Serialize, Ts)]
#[ts(readonly)]
#[serde(rename_all = "camelCase")]
pub struct AuditEntry {
    pub actor_id: u64,
    #[ts(inline)]
    pub window: Span,
}
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:

#[derive(Serialize, Ts)]
#[serde(tag = "kind", rename_all = "camelCase")]
enum Event {
    Click { x: i32, y: i32 },
    KeyPress { code: String },
}
export type Event =
  | { kind: "click"; x: number; y: number }
  | { kind: "keyPress"; code: string };

CI

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

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 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:

// 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:

// 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 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