Skip to content

Deriving the TS trait

escritorio-gustavo edited this page Mar 7, 2024 · 26 revisions

#[derive(TS)]

The TS trait can be easily derived through its #[derive(TS)] macro, which will automatically handle mapping a Rust type into TypeScript type definitions.

The #[derive(TS)] macro provides an attribute helper macro called #[ts(...)] which can help you control how the types will be generated

#[ts(...)] attributes

Container attributes

These are attributes that can be used both with structs and enums

#[ts(export)]

This attribute causes the generation of a test which will create a ts file containing the TypeScript declaration for your type, as well as any types it depends on (as long as they implement TS)

#[ts(export_to = "...")]

Allows you to change where your TypeScript file will be generated. The default is ./bindings/TypeName.ts, this path is relative to Cargo.toml.

Usage:

#[derive(ts_rs::TS)]
#[ts(export)]
struct MyStruct {
    foo: String
}

This must be either:

  • An absolute path
  • A path relative to your crate's Cargo.toml

The given path will be treated as a directory if it ends with a / character, in which case a file called TypeName.ts will be created within the given directory, otherwise the path will be treated as a file (even without an extension).

Usage:

#[derive(ts_rs::TS)]
#[ts(export, export_to = "../ts_project/bindings/")] // Note that #[ts(export)] is still required
struct MyStruct {
    foo: String
}

If you see yourself using #[ts_export = "..."] with the same directory for a lot (or even all) your types, there is a more convenient way to do this.

Create a directory at the root of your project (the directory that contains Cargo.toml) called .cargo. Inside the .cargo directory, create a file called config.toml and type the following:

[env]
TS_RS_EXPORT_DIR = { value = "...", relative = true }

Where value is a path to a directory (i.e. it must end with a slash /) relative to your Cargo.toml file.

Now, using #[ts(export)] without #[ts(export_to = "...")] will result in exporting to the directory defined in your config.toml file and if you use #[ts(export_to = "...")], its path will be relative to that directory.

#[ts(rename = "...")]

Changes the name of your type's TypeScript representation.

If the feature flag serde-compat is enabled (default), using #[serde(rename = "...")] will have the same effect.

Usage:

#[derive(ts_rs::TS)]
#[ts(export, rename = "MyType")]
struct MyStruct {
    foo: String
}

Generates:

export type MyType = { foo: string, };

#[ts(rename_all = "...")]

Renames all the fields in your struct or variants in your enum to use a given inflection.

Accepted values are lowercase, snake_case, kebab-case, UPPERCASE, camelCase, PascalCase and SCREAMING_SNAKE_CASE.

If the feature flag serde-compat is enabled (default), using #[serde(rename_all = "...")] will have the same effect.

Usage:

#[derive(ts_rs::TS)]
#[ts(export, rename_all = "camelCase")]
struct MyStruct {
    foo_bar: String
}

Generates:

export type MyStruct = { fooBar: string, };

Struct attributes

#[ts(tag = "...")]

Add the struct's name (or value of #[ts(rename = "...")]) as a field with the given key.

Usage:

#[derive(ts_rs::TS)]
#[ts(export, tag = "hello")]
struct MyStruct {
    foo_bar: String
}

Generates:

export type MyStruct = { "hello": "MyStruct", fooBar: string, };

Struct field attributes

#[ts(inline)]

Inlines the type of this field, replacing its name with its definition.

Usage:

#[derive(TS)]
struct Pagination {
    limit: u32,
    offset: u32,
    total: u32,
}

#[derive(TS)]
struct Users {
    users: Vec<HashMap<String, String>>,

    #[ts(inline)]
    pagination: Pagination,
}

Generates:

export type Users = {
    users: Array<Record<string, string>>;
    pagination: {
        limit: number;
        offset: number;
        total: number;
    };
};

#[ts(flatten)]

The #[ts(flatten)] attribute inlines keys from a field into the parent struct.

Any struct, enum or map may be flattened, but beware that you should not flatten an externally tagged enum that contains non-skipped unit variants, like the following:

#[derive(TS)]
// The absence of #[ts(tag = "...")], #[ts(tag = "...", content = "...")] or #[ts(untagged)]
// means this enum is externally tagged
enum MyEnum {
    Foo, // Unit variant, #[ts(skip)] not used
    Bar(u32)
}

#[derive(TS)]
struct MyStruct {
    biz: i32,

    #[ts(flatten)] // You should not do this
    qux: MyEnum
}

Why? Well, this generates the following TS code:

export type MyStruct = { biz: number, } & ("Foo" | { "Bar": number })

So in case of the Foo variant, your type will be { biz: number } & 'Foo', which doesn't make any sense.

Also note that this is valid (though heavily discouraged) if the flattened enum is the only field in your struct, because

#[derive(TS)]
struct MyStruct {
    #[ts(flatten)] // You should still not do this, even though the TS is valid
    qux: MyEnum
}

Generates:

export type MyStruct = "Foo" | { "Bar": number }

Which is valid TS, but serde will still fail to (de)serialize it

If the feature flag serde-compat is enabled (default), using #[serde(flatten)] will have the same effect.

Usage:

#[derive(TS)]
struct Pagination {
    limit: u32,
    offset: u32,
    total: u32,
}

#[derive(TS)]
struct Users {
    users: Vec<HashMap<String, String>>,

    #[ts(flatten)]
    pagination: Pagination,
}

Generates:

export type Users = {
    users: Array<Record<string, string>>;
    limit: number;
    offset: number;
    total: number;
};

#[ts(as = "...")]

Say you are using a crate called foo that has the following struct:

// foo/lib.rs
pub Foo {
    bar: u32
}

In your code, you wish to have something like:

#[derive(TS)]
struct MyCoolStruct {
    my_field: foo::Foo, // Compiler error: foo::Foo doesn't implement `TS`
}

When you are using a type from an external crate that does not implement TS, it becomes impossible to use this type normally as the type for a struct field, since you cannot implement it yourself due to the orphan rule.

#[ts(as = "...")] helps you solve that problem.

You will need to create a duplicate of foo::Foo with the same fields, which derives TS, then use #[ts(as = "...")] to point to that struct:

#[derive(TS)]
pub FooDef {
    bar: u32,
}

#[derive(TS)]
struct MyCoolStruct {
    #[ts(as = "FooDef")]
    my_field: foo::Foo,
}

Now, to generate the TypeScript code, ts_rs will use FooDef instead of Foo, avoiding the compiler error.

#[ts(type = "...")]

Manually override the type emitted in the TypeScript code. This is generally not recommended unless you have no other option.

This also avoids the issue that #[ts(as = "...")] solves, but at the cost of handwriting the TS type in a string, potentially introducing TS syntax errors.

// foo/lib.rs
pub Foo {
    bar: u32
}
#[derive(TS)]
struct MyCoolStruct {
    #[ts(type = "{ bar: number }")]
    my_field: foo::Foo,

    #[ts(type = "string")]
    other_field: u32,

    #[ts(type = "{ bar; number }")]
                   // ^ This should be a colon
    uh_oh: foo::Foo,
}

This will generate:

export type MyCoolStruct = {
    my_field: { bar: number };
    other_field: string;
    uh_oh: { bar; number };
             // ^ The contents of `#[ts(type = "...")]` are copied
             //   verbatim, so the syntax error is carried over
};

#[ts(optional)]

May be applied on a struct field of type Option<T>. By default, such a field would turn into t: T | null. If #[ts(optional)] is present, t?: T is generated instead.

Usage:

#[derive(TS)]
#[ts(export)]
struct Foo {
    #[ts(optional)]
    bar: Option<u32>,
}

Generates

export type Foo = { bar?: number, };

#[ts(optional = nullable)]

May be applied on a struct field of type Option<T>. By default, such a field would turn into t: T | null. If #[ts(optional = nullable)] is present, t?: T | null is generated.

Usage:

#[derive(TS)]
#[ts(export)]
struct Foo {
    #[ts(optional = nullable)]
    bar: Option<u32>,
}

Generates

export type Foo = { bar?: number | null, };

#[ts(skip)]

Avoids generating TS definitions for a field.

If the feature flag serde-compat is enabled (default), using #[serde(skip)] will have the same effect.

Usage:

#[derive(TS)]
#[ts(export)]
struct Foo {
    #[ts(skip)]
    bar: u32,
    baz: String,
}

Generates

export type Foo = { baz: string, };

#[ts(rename = "...")]

Changes the name of the field in the type's TS representation.

If the feature flag serde-compat is enabled (default), using #[serde(rename = "...")] will have the same effect.

Usage:

#[derive(TS)]
#[ts(export)]
struct Foo {
    #[ts(rename = "biz")]
    bar: u32,
}

Generates

export type Foo = { biz: number, };

Enum attibutes

By default, enum type definitions will match serde's externally tagged enums, which means

#[derive(TS)]
#[ts(export)]
enum Message {
    Request { id: String, method: String, params: HashMap<String, String> },
    Response { id: String, status: u8 },
}

Generates:

// Note: this code snipped has been formatted manually to facilitate readability
// The exported code is not guaranteed to match this formatting, even with the
// "format" feature flag
export type Message =
    | { "Request": { id: string, method: string, params: Record<string, string> } }
    | { "Response": { id: string, status: number } };

This behavior can be changed with the attributes #[ts(tag = "...")], #[ts(tag = "...", content = "...")] and #[ts(untagged)]. Like other attributes, when the serde-compat feature is enabled, using the serde version of these will also apply the behavior described below.

#[ts(tag = "...")]

Generates TS types for a serde internally tagged enum (TS calls these "discriminated unions"). Beware that this type of enum may not contain tuple variants, as that will generate invalid TypeScript, as well as cause a runtime panic! when using serde. Newtype variants also have the same issue, unless they are a new type over a struct with named fields.

If the feature flag serde-compat is enabled (default), using #[serde(tag = "...")] will have the same effect.

Usage:

#[derive(TS)]
#[ts(tag = "type")]
enum Message {
    Request { id: String, method: String, params: HashMap<String, String> },
    Response { id: String, status: u8 },
}

Geneates

// Note: this code snipped has been formatted manually to facilitate readability
// The exported code is not guaranteed to match this formatting, even with the
// "format" feature flag
export type Message =
    | { "type": "Request", method: string, params: Record<string, string> }
    | { "type": "Response", status: number };

#[ts(tag = "...", content = "...")]

Generates TS types for a serde adjacently tagged enum.

If the feature flag serde-compat is enabled (default), using #[serde(tag = "...", content = "...")] will have the same effect.

Usage:

#[derive(TS)]
#[ts(tag = "t", content = "c")]
enum Block {
    Para(Vec<String>),
    Str(String),
}

Geneates

export type Block = { "t": "Para", "c": Array<string>, } | { "t": "Str", "c": string, };

#[ts(untagged)]

Generates TS types for a serde untagged enum.

If the feature flag serde-compat is enabled (default), using #[serde(untagged)] will have the same effect.

Usage:

#[derive(TS)]
#[ts(untagged)]
enum Message {
    Request { id: String, method: String, params: HashMap<String, String> },
    Response { id: String, status: u8 },
}

Geneates

export type Message = { id: string, method: string, params: Record<string, string>, } | { id: string, status: number, };

#[ts(rename_all_fields = "...")]

Equivalent to adding #[ts(rename_all = "..."] to every variant.

Accepted values are lowercase, snake_case, kebab-case, UPPERCASE, camelCase, PascalCase and SCREAMING_SNAKE_CASE.

If the feature flag serde-compat is enabled (default), using #[serde(rename_all_fields = "...")] will have the same effect.

Usage:

#[derive(TS)]
#[ts(export, rename_all_fields = "UPPERCASE")]
enum Message {
    Request { id: String, method: String, params: HashMap<String, String> },
    Response { id: String, status: u8 },
}

Generates:

// Note: this code snipped has been formatted manually to facilitate readability
// The exported code is not guaranteed to match this formatting, even with the
// "format" feature flag
export type Message =
    | { "Request": { ID: string, METHOD: string, PARAMS: Record<string, string> } }
    | { "Response": { ID: string, STATUS: number } };

Enum variant attributes

#[ts(inline)]

#[ts(skip)]

Avoids generating TS definitions for a variant.

If the feature flag serde-compat is enabled (default), using #[serde(skip)] will have the same effect.

Usage:

#[derive(TS)]
#[ts(export)]
enum Message {
    #[ts(skip)]
    Request { id: String, method: String, params: HashMap<String, String> },
    Response { id: String, status: u8 },
}

Generates:

// Note: this code snipped has been formatted manually to facilitate readability
// The exported code is not guaranteed to match this formatting, even with the
// "format" feature flag
export type Message = { "Response": { id: string, status: number } };

#[ts(rename = "...")]

Changes the name of a variant's tag (has no effect on untagged enums or variants).

If the feature flag serde-compat is enabled (default), using #[serde(rename = "...")] will have the same effect.

Usage:

#[derive(TS)]
#[ts(export)]
enum Message {
    #[ts(rename = "req")]
    Request { id: String, method: String, params: HashMap<String, String> },

    #[ts(rename = "res")]
    Response { id: String, status: u8 },
}

Generates:

// Note: this code snipped has been formatted manually to facilitate readability
// The exported code is not guaranteed to match this formatting, even with the
// "format" feature flag
export type Message =
    | { "req": { id: string, method: string, params: Record<string, string> } }
    | { "res": { id: string, status: number } };

#[ts(rename_all = "...")]

In an enum's struct variant, renames all the fields of the variant to use a given inflection.

Accepted values are lowercase, snake_case, kebab-case, UPPERCASE, camelCase, PascalCase and SCREAMING_SNAKE_CASE.

If the feature flag serde-compat is enabled (default), using #[serde(rename_all = "...")] will have the same effect.

Usage:

#[derive(TS)]
#[ts(export)]
enum Message {
    #[ts(rename_all = "UPPERCASE")]
    Request { id: String, method: String, params: HashMap<String, String> },
    Response { id: String, status: u8 },
}

Generates:

// Note: this code snipped has been formatted manually to facilitate readability
// The exported code is not guaranteed to match this formatting, even with the
// "format" feature flag
export type Message =
    | { "Request": { ID: string, METHOD: string, PARAMS: Record<string, string> } }
    | { "Response": { id: string, status: number } };

#[ts(untagged)]

Allows specific enum variants to be treated as untagged, regardless of the enums #[ts(tag = "...")] or #[ts(tag = "...", content = "...")] attribute.

If the feature flag serde-compat is enabled (default), using #[serde(untagged)] will have the same effect.

Usage:

#[derive(TS)]
#[ts(export)]
enum Message {
    Request { id: String, method: String, params: HashMap<String, String> },

    #[ts(untagged)]
    Response { id: String, status: u8 },
}

Generates:

// Note: this code snipped has been formatted manually to facilitate readability
// The exported code is not guaranteed to match this formatting, even with the
// "format" feature flag
export type Message =
    | { "Request": { id: string, method: string, params: Record<string, string> } }
    | { id: string, status: number };