Skip to content

Generating TypeScript enums - #79

Open
hgiesel wants to merge 15 commits into
madonoharu:mainfrom
hgiesel:feat/value-enum
Open

hgiesel wants to merge 15 commits into
madonoharu:mainfrom
hgiesel:feat/value-enum

Conversation

@hgiesel

@hgiesel hgiesel commented Feb 26, 2026

Copy link
Copy Markdown
Contributor

(see #80)

Adds two different kinds of enums:

  1. discriminant enums

Can be generated with enums (possibly along namespaces)

#[derive(Tsify)]
#[tsify(discriminants)]
#[serde(tag = "t")]
enum Internal {
    /// Comment for Struct
    Struct { x: String, y: i32 },
    /// Comment for EmptyStruct
    EmptyStruct {},
    /// Comment for Newtype
    Newtype(Foo),
    /// Comment for Unit
    Unit,
}

Generates

export enum InternalT {
    /**
     * Comment for Struct
     */
    Struct = "Struct",
    /**
     * Comment for EmptyStruct
     */
    EmptyStruct = "EmptyStruct",
    /**
     * Comment for Newtype
     */
    Newtype = "Newtype",
    /**
     * Comment for Unit
     */
    Unit = "Unit",
}

/**
 * Comment for Internal
 */
export type Internal = { t: InternalT.Struct; x: string; y: number } | { t: InternalT.EmptyStruct } | ({ t: InternalT.Newtype } & Foo) | { t: InternalT.Unit };
  1. Value enums

A limited subset of rust enums (only unit variants, externally tagged) can be turned into value enums.

#[derive(Tsify)]
#[tsify(value_enum, rename_variants)]
#[serde(rename_all = "kebab-case")]
enum External {
    /// Comment for Struct
    Alpha,
    /// Comment for EmptyStruct
    Beta,
    /// Comment for Tuple
    GammaDelta,
}
export enum External {
    /**
     * Comment for Struct
     */
    Alpha = "alpha",
    /**
     * Comment for EmptyStruct
     */
    Beta = "beta",
    /**
     * Comment for Tuple
     */
    GammaDelta = "gamma-delta",
}"#

The rename_variants is another new keyword. It makes sure that serde only renames the discriminant, and not the type referring to it. It also works with namespaces:

#[derive(Tsify)]
#[serde(rename_all = "camelCase")]
#[tsify(namespace, rename_variants)]
enum Internal {
    Struct { x: String, y: i32 },
    EmptyStruct {},
    Tuple(i32, String),
    EmptyTuple(),
    Newtype(Foo),
    Unit,
}
type __InternalFoo = Foo;
declare namespace Internal {
    export type Struct = { struct: { x: string; y: number } };
    export type EmptyStruct = { emptyStruct: {} };
    export type Tuple = { tuple: [number, string] };
    export type EmptyTuple = { emptyTuple: [] };
    export type Newtype = { newtype: __InternalFoo };
    export type Unit = "unit";
}

export type Internal = { struct: { x: string; y: number } } | { emptyStruct: {} } | { tuple: [number, string] } | { emptyTuple: [] } | { newtype: Foo } | "unit";

The idea behind the name rename_variants, that it could be extended to pass values defining how the variant should be renamed, which is unimplemented rn, open to adjust the naming.

@hgiesel hgiesel changed the title C Generating TypeScript enums Feb 26, 2026
@hgiesel
hgiesel marked this pull request as draft February 26, 2026 03:08
@hgiesel
hgiesel marked this pull request as ready for review February 26, 2026 03:33
@hgiesel hgiesel mentioned this pull request Feb 26, 2026
@madonoharu

Copy link
Copy Markdown
Owner

Sorry for the silence — this PR has had no review at all since you opened it, and that is on us, not on you. Here is a real one.

Everything below was reproduced on this branch (e9ddba9) with cargo test, and the TypeScript was checked with tsc --strict.

Summary

# Area What happens Severity
1 discriminants Derive panics on a serde tag that is not a Rust identifier Blocker
2 Enum members Names are interpolated into TS and JS with no escaping Blocker
3 discriminants Non-identifier variant names produce a TS syntax error at the reference site Blocker
4 discriminants The generated enum can collide with the type's own declaration Blocker
5 Cargo.toml reexport + skip_typescript needs wasm-bindgen 0.2.115; the floor is 0.2.104 Blocker (one-line fix)
6 wasm_bindgen.rs clippy needless_borrow — the current CI Lint failure Blocker (one-line fix)
7 discriminants type_prefix/type_suffix don't reach the generated enum name Consistency
8 value_enum The container's doc comment is dropped Consistency
9 value_enum namespace and discriminants are accepted and silently ignored Diagnostics
10 discriminants Accepted on #[serde(untagged)], where nothing can reference the result Diagnostics
11 rename_variants The name says the opposite of what it does API naming
12 attrs.rs The "unsupported attribute" message names an attribute that doesn't exist Polish

1–4 are design calls; 5 and 6 are one-line fixes.

1. The derive panics on a non-identifier serde tag

#[derive(Tsify)]
#[tsify(discriminants)]
#[serde(tag = "a-b")]
enum Internal { A { x: i32 }, B }
error: proc-macro derive panicked
 --> tests/zz_panic.rs:4:10
  |
4 | #[derive(Tsify)]
  |          ^^^^^
  |
  = help: message: `"InternalA-b"` is not a valid identifier

attrs.rs builds the default name by capitalizing the serde tag and concatenating (format!("{}{}", input.ident, tag)), and wasm_bindgen.rs then hands it to syn::Ident::new, which panics rather than returning an error. The span lands on #[derive(Tsify)] with nothing pointing at #[serde(tag = "a-b")], so the user has no way to tell what caused it. #[tsify(discriminants = "My-Disc")] panics the same way.

This should be a syn::Error spanned to the offending attribute. The name would still be invalid TypeScript either way, so the check belongs on both paths.

2. Enum member names are not escaped

#[derive(Tsify)]
#[tsify(value_enum)]
enum Quoted {
    #[serde(rename = "a\"b")]
    A,
}
export enum Quoted {
    "a"b" = "a"b",
}

basic.rs quotes non-identifiers but never escapes the contents. The same string is interpolated into the inline JS in wasm_bindgen.rs, so this is a broken bundle rather than a compile error. Backslashes break the same way.

3. Non-identifier variant names break the reference site

is_js_ident guards TsTypeElementKey::Lit and TsValueEnumMember, but not TsTypeElementKey::Var, which is what the union uses. Raw identifiers show it most clearly:

#[derive(Tsify)]
#[tsify(discriminants, rename_variants)]
#[serde(tag = "t")]
enum Raw { r#type { x: i32 }, r#struct { y: i32 } }
export enum RawT {
    "r#type" = "type",
    "r#struct" = "struct",
}

export type Raw = { t: RawT.r#type; x: number } | { t: RawT.r#struct; y: number };
raw.ts(2,30): error TS1005: ';' expected.

Two things are wrong here. The member id keeps the r# prefix while its value is the serialized name, so the key and the wire value disagree. And RawT.r#type is not parseable — the member is quoted in the declaration, so the reference has to be RawT["r#type"].

rename_all reaches the same place without raw identifiers, which is the more likely way to hit it:

#[tsify(discriminants)]
#[serde(tag = "t", rename_all = "kebab-case")]
enum Kebab { SomeName { x: i32 }, OtherName { y: i32 } }
export type Kebab = { t: KebabT.some-name; x: number } | { t: KebabT.other-name; y: number };

4. The generated enum can collide with the type's own name

#[derive(Tsify)]
#[tsify(discriminants = "Collide")]
#[serde(tag = "t")]
enum Collide { A { x: i32 }, B }
export enum Collide { A = "A", B = "B" }
export type Collide = { t: Collide.A; x: number } | { t: Collide.B };
collide.ts(1,13): error TS2567: Enum declarations can only merge with namespace or other enum declarations.
collide.ts(2,13): error TS2567: Enum declarations can only merge with namespace or other enum declarations.

Two types picking the same discriminant name collide the same way, and the JS side then emits two export { X } from the glue module.

5. The wasm-bindgen floor has to move to 0.2.115

wasm_bindgen.rs emits #[wasm_bindgen(reexport, js_name = ..., skip_typescript)]. The manifest still asks for 0.2.104, which allows three different behaviors:

Resolved version What happens
< 0.2.106 reexport does not exist — compile error
0.2.106 – 0.2.114 Compiles, but skip_typescript is ignored when combined with reexport (fixed in 0.2.115), so wasm-bindgen emits its own declaration on top of the one tsify already writes
0.2.115+ Correct

The middle row is the dangerous one: it builds, and the duplicate lands in the .d.ts. CI resolves the latest version, so it cannot catch either.

6. clippy fails on one character

warning: this expression creates a reference which is immediately dereferenced by the compiler
  --> tsify-macros/src/wasm_bindgen.rs:61:45
help: change this to: value

value is already &TsValueEnumDecl. CI runs cargo clippy -- -D warnings, and this is the only warning in the tree, so it is the whole of the current Lint failure.

7. The affix does not reach the generated enum name

#[tsify(type_prefix = "Ts", discriminants)]
#[serde(tag = "t")]
enum Affixed { A { x: i32 }, B }
export enum AffixedT { A = "A", B = "B" }
export type TsAffixed = { t: AffixedT.A; x: number } | { t: AffixedT.B };

Every other generated name goes through ty_config.format_name — see ident_str in container.rs. The README asks for the affixes to be applied crate-wide, but to_enum_decl() in attrs.rs builds the discriminant id straight from the string stored at parse time and never calls format_name, so the affix has no path to it.

A second half to this landed after you opened the PR. main now has #[tsify(rename = "...")], and declaration names come from Container::declaration_name(). parse_value_enum still uses container.ident_str(), and the discriminants default still uses input.ident — worth routing both through declaration_name() in the same pass, or a renamed type will get one name for the union and the Rust ident for the enum.

8. value_enum drops the container's doc comment

/// Doc for Color
#[derive(Tsify)]
#[tsify(value_enum)]
enum Color {
    /// Doc for Red
    Red,
    Green,
}
export enum Color {
    /**
     * Doc for Red
     */
    Red = "Red",
    Green = "Green",
}

Variant comments survive, which makes the missing one easy to overlook — TsValueEnumDecl has no comments field, while TsTypeAliasDecl, TsInterfaceDecl, and TsEnumDecl all carry one. tests/value_enum.rs currently bakes the absence into its expectation.

9. value_enum silently ignores namespace and discriminants

#[tsify(value_enum, namespace)] passes validation and produces exactly what value_enum alone produces — parse_value_enum never reads attrs.namespace. Same for value_enum combined with discriminants.

10. discriminants is accepted on untagged enums, where nothing can reference it

#[tsify(discriminants)] on an untagged enum emits an enum nothing refers to:

export enum UntaggedType { A = "A", B = "B" }
export type Untagged = { x: number } | { y: number };

With this PR that dead enum also ships a runtime object. value_enum already rejects the tagging modes it cannot support; the same treatment here would be consistent.

11. rename_variants says the opposite of what it does

The attribute reads as "rename the variants", but the code does the opposite:

let member_value = if self.container.attrs.rename_variants {
    variant.ident.to_string()          // ignore serde's rename, use the Rust ident
} else {
    variant_serialized.to_owned()      // follow serde
};

The doc comment on the field says as much: "just defining it means it shouldn't change." And outside value_enum and discriminants, setting it alone changes nothing visible — the name it produces is only ever printed inside a namespace or a discriminant.

This isn't a blocker; the name just doesn't say what it does. It's the same call as type_aliasas_type_alias on #77, and it's a public attribute name. @siefkenj, if you have a preference, say so — otherwise I'll pick a name before this lands, so it doesn't need a second rename after release.

12. The "unsupported attribute" message

It currently reads expected one of ... `rename_variant`, namespace`, `discriminants` .... rename_variant is singular and doesn't exist, and namespace is missing its opening backtick, so following the message verbatim produces another error. attrs.rs also has "generic generic enums" in the value_enum diagnostic.

Where this leaves things

The design is good, and the test coverage in tests/discriminants.rs is more thorough than most of what is in this repo. What has to happen before this can land is 1–4; 5 and 6 are one-line fixes once you're in there anyway.

main has moved a fair way since February — Ts<T>, #[tsify(rename)], and a refactor of the type conversion path touching attrs.rs, decl.rs, parser.rs, and ts_type.rs. Rebasing this won't be mechanical. If you'd rather not carry that yourself, say the word and I'll do it on top of your commits. Either way is fine — it's your call.

@madonoharu madonoharu mentioned this pull request Aug 29, 2026
@madonoharu madonoharu added this to the 0.6.0 milestone Aug 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants