Repository: Aleph-Alpha/ts-rs
Pull request #282 (2024-03-22 12:26:39)

Fix bug in enum flattening
## Goal

When a struct containing a flattened enum was itself flattened, the type generated would be missing the enum
That is, the following code

```rs
#[derive(TS)]
struct FooExternally {
    qux: i32,
    #[ts(flatten)]
    baz: BarExternally,
    biz: Option<String>,
}

#[derive(TS)]
enum BarExternally {
    Baz { a: i32, a2: String },
    Biz { b: bool },
    Buz { c: String, d: Option<i32> },
}

#[derive(TS)]
#[ts(export, export_to = "enum_flattening/externally_tagged/")]
struct NestedExternally {
    #[ts(flatten)]
    a: FooExternally,
    u: u32,
}
```

Would generate the following TS:
```ts
export type NestedExternally = { u: number, qux: number, biz: string | null, };
```

When it should generate
```ts
export type NestedExternally = { u: number, qux: number, biz: string | null, } & (
    | { "Baz": { a: number, a2: string, } }
    | { "Biz": { b: boolean, } }
    | { "Buz": { c: string, d: number | null, } }
);
```

## Changes

Updated the generation of `inline_flattened` in `macros/src/types/named.rs` to be similar to `inline`, except for not removing parenthesis around lonely flattened fields, as that caused issues when flattening a struct that contained only a flattened enum. (The `replace(" } & { ", " ")` would merge the fields with the first enum variant)

## Checklist

- [x] I have followed the steps listed in the [Contributing guide](https://github.com/Aleph-Alpha/ts-rs/blob/main/CONTRIBUTING.md).
- [x] If necessary, I have added documentation related to the changes made.
- [x] I have added or updated the tests related to the changes made.


NyxCode: Nice, thanks!  
I never realized that `inline_flattened` would just drop flattened fields. Amazing that the test suite didnt catch that.
escritorio-gustavo: > Nice, thanks! I never realized that `inline_flattened` would just drop flattened fields. Amazing that the test suite didnt catch that.

Me neither! I only realized because I was looking at some old PRs and when I got to #206 I went "wtf was I thinking" lol

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 12adc59..6dd23e4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,12 +1,16 @@
 # master
+
 ### Breaking
+
 ### Features
-- Add `#[ts(crate = "..")]` to allow usage of `#[derive(TS)]` from other proc-macro crates  ([#274](https://github.com/Aleph-Alpha/ts-rs/pull/274))
+
+- Add `#[ts(crate = "..")]` to allow usage of `#[derive(TS)]` from other proc-macro crates ([#274](https://github.com/Aleph-Alpha/ts-rs/pull/274))
 - Add support types from `serde_json` behind cargo feature `serde-json-impl` ([#276](https://github.com/Aleph-Alpha/ts-rs/pull/276))
 
 ### Fixes
 
 - Macro expansion for types with generic parameters now works without the `TS` trait in scope ([#281](https://github.com/Aleph-Alpha/ts-rs/pull/281))
+- Fix enum flattening a struct that contains a flattened enum ([#282](https://github.com/Aleph-Alpha/ts-rs/pull/282))
 
 # v8.0.0
 
diff --git a/macros/src/types/named.rs b/macros/src/types/named.rs
index c84fa6c..c0ddb43 100644
--- a/macros/src/types/named.rs
+++ b/macros/src/types/named.rs
@@ -47,12 +47,19 @@ pub(crate) fn named(attr: &StructAttr, name: &str, fields: &FieldsNamed) -> Resu
         (_, _) => quote!(format!("{{ {} }} & {}", #fields, #flattened)),
     };
 
+    let inline_flattened = match (formatted_fields.len(), flattened_fields.len()) {
+        (0, 0) => quote!("{  }".to_owned()),
+        (_, 0) => quote!(format!("{{ {} }}", #fields)),
+        (0, _) => quote!(#flattened),
+        (_, _) => quote!(format!("{{ {} }} & {}", #fields, #flattened)),
+    };
+
     Ok(DerivedTS {
         crate_rename,
         // the `replace` combines `{ ... } & { ... }` into just one `{ ... }`. Not necessary, but it
         // results in simpler type definitions.
         inline: quote!(#inline.replace(" } & { ", " ")),
-        inline_flattened: Some(quote!(format!("{{ {} }}", #fields))),
+        inline_flattened: Some(quote!(#inline_flattened.replace(" } & { ", " "))),
         docs: attr.docs.clone(),
         dependencies,
         export: attr.export,


Test patch:
diff --git a/ts-rs/tests/enum_flattening.rs b/ts-rs/tests/enum_flattening.rs
index 6a0e1f9..b86850b 100644
--- a/ts-rs/tests/enum_flattening.rs
+++ b/ts-rs/tests/enum_flattening.rs
@@ -24,12 +24,26 @@ enum BarExternally {
     Buz { c: String, d: Option<i32> },
 }
 
+#[derive(TS)]
+#[cfg_attr(feature = "serde-compat", derive(Serialize))]
+#[ts(export, export_to = "enum_flattening/externally_tagged/")]
+struct NestedExternally {
+    #[cfg_attr(feature = "serde-compat", serde(flatten))]
+    #[cfg_attr(not(feature = "serde-compat"), ts(flatten))]
+    a: FooExternally,
+    u: u32,
+}
+
 #[test]
 fn externally_tagged() {
     assert_eq!(
         FooExternally::inline(),
         r#"{ qux: number, biz: string | null, } & ({ "Baz": { a: number, a2: string, } } | { "Biz": { b: boolean, } } | { "Buz": { c: string, d: number | null, } })"#
-    )
+    );
+    assert_eq!(
+        NestedExternally::inline(),
+        r#"{ u: number, qux: number, biz: string | null, } & ({ "Baz": { a: number, a2: string, } } | { "Biz": { b: boolean, } } | { "Buz": { c: string, d: number | null, } })"#
+    );
 }
 
 #[derive(TS)]
@@ -65,12 +79,25 @@ enum BarAdjecently {
     },
 }
 
+#[derive(TS)]
+#[cfg_attr(feature = "serde-compat", derive(Serialize))]
+struct NestedAdjecently {
+    #[cfg_attr(feature = "serde-compat", serde(flatten))]
+    #[cfg_attr(not(feature = "serde-compat"), ts(flatten))]
+    a: FooAdjecently,
+    u: u32,
+}
+
 #[test]
 fn adjacently_tagged() {
     assert_eq!(
         FooAdjecently::inline(),
         r#"{ one: number, qux: string | null, } & ({ "type": "Baz", "stuff": { a: number, a2: string, } } | { "type": "Biz", "stuff": { b: boolean, } } | { c: string, d: number | null, })"#
-    )
+    );
+    assert_eq!(
+        NestedAdjecently::inline(),
+        r#"{ u: number, one: number, qux: string | null, } & ({ "type": "Baz", "stuff": { a: number, a2: string, } } | { "type": "Biz", "stuff": { b: boolean, } } | { c: string, d: number | null, })"#
+    );
 }
 
 #[derive(TS)]
@@ -95,23 +122,46 @@ enum BarInternally {
     Buz { c: String, d: Option<i32> },
 }
 
+#[derive(TS)]
+#[cfg_attr(feature = "serde-compat", derive(Serialize))]
+struct NestedInternally {
+    #[cfg_attr(feature = "serde-compat", serde(flatten))]
+    #[cfg_attr(not(feature = "serde-compat"), ts(flatten))]
+    a: FooInternally,
+    u: u32,
+}
+
 #[test]
 fn internally_tagged() {
     assert_eq!(
         FooInternally::inline(),
         r#"{ qux: string | null, } & ({ "type": "Baz", a: number, a2: string, } | { "type": "Biz", b: boolean, } | { "type": "Buz", c: string, d: number | null, })"#
-    )
+    );
+    assert_eq!(
+        NestedInternally::inline(),
+        r#"{ u: number, qux: string | null, } & ({ "type": "Baz", a: number, a2: string, } | { "type": "Biz", b: boolean, } | { "type": "Buz", c: string, d: number | null, })"#
+    );
 }
 
 #[derive(TS)]
 #[ts(export, export_to = "enum_flattening/untagged/")]
 #[cfg_attr(feature = "serde-compat", derive(Serialize))]
 struct FooUntagged {
+    one: u32,
     #[cfg_attr(feature = "serde-compat", serde(flatten))]
     #[cfg_attr(not(feature = "serde-compat"), ts(flatten))]
     baz: BarUntagged,
 }
 
+#[derive(TS)]
+#[cfg_attr(feature = "serde-compat", derive(Serialize))]
+struct NestedUntagged {
+    #[cfg_attr(feature = "serde-compat", serde(flatten))]
+    #[cfg_attr(not(feature = "serde-compat"), ts(flatten))]
+    a: FooUntagged,
+    u: u32,
+}
+
 #[derive(TS)]
 #[ts(export, export_to = "enum_flattening/untagged/")]
 #[cfg_attr(feature = "serde-compat", derive(Serialize))]
@@ -127,6 +177,10 @@ enum BarUntagged {
 fn untagged() {
     assert_eq!(
         FooUntagged::inline(),
-        r#"{ a: number, a2: string, } | { b: boolean, } | { c: string, }"#
-    )
+        r#"{ one: number, } & ({ a: number, a2: string, } | { b: boolean, } | { c: string, })"#
+    );
+    assert_eq!(
+        NestedUntagged::inline(),
+        r#"{ u: number, one: number, } & ({ a: number, a2: string, } | { b: boolean, } | { c: string, })"#
+    );
 }
diff --git a/ts-rs/tests/enum_flattening_nested.rs b/ts-rs/tests/enum_flattening_nested.rs
new file mode 100644
index 0000000..c15d690
--- /dev/null
+++ b/ts-rs/tests/enum_flattening_nested.rs
@@ -0,0 +1,243 @@
+#![allow(dead_code)]
+
+#[cfg(feature = "serde-compat")]
+use serde::Serialize;
+use ts_rs::TS;
+
+#[derive(TS)]
+#[cfg_attr(feature = "serde-compat", derive(Serialize))]
+#[ts(export, export_to = "enum_flattening_nested/externally_tagged/")]
+struct FooExternally {
+    #[cfg_attr(feature = "serde-compat", serde(flatten))]
+    #[cfg_attr(not(feature = "serde-compat"), ts(flatten))]
+    baz: BarExternally,
+}
+
+#[derive(TS)]
+#[cfg_attr(feature = "serde-compat", derive(Serialize))]
+#[ts(export, export_to = "enum_flattening_nested/externally_tagged/")]
+enum BarExternally {
+    Baz { a: i32, a2: String },
+    Biz { b: bool },
+    Buz { c: String, d: Option<i32> },
+}
+
+#[derive(TS)]
+#[cfg_attr(feature = "serde-compat", derive(Serialize))]
+#[ts(export, export_to = "enum_flattening_nested/externally_tagged/")]
+struct NestedExternally {
+    #[cfg_attr(feature = "serde-compat", serde(flatten))]
+    #[cfg_attr(not(feature = "serde-compat"), ts(flatten))]
+    a: FooExternally,
+    u: u32,
+}
+
+#[derive(TS)]
+#[cfg_attr(feature = "serde-compat", derive(Serialize))]
+#[ts(export, export_to = "enum_flattening_nested/externally_tagged/")]
+struct NestedExternallyLonely {
+    #[cfg_attr(feature = "serde-compat", serde(flatten))]
+    #[cfg_attr(not(feature = "serde-compat"), ts(flatten))]
+    a: FooExternally,
+}
+
+#[test]
+fn externally_tagged() {
+    // Notice here that baz is the only field inside `FooExternally`, so the parenthesis
+    // aren't needed
+    assert_eq!(
+        FooExternally::inline(),
+        r#"{ "Baz": { a: number, a2: string, } } | { "Biz": { b: boolean, } } | { "Buz": { c: string, d: number | null, } }"#
+    );
+
+    // But when flattening, the parenthesis are needed due to type intesections
+    assert_eq!(
+        NestedExternally::inline(),
+        r#"{ u: number, } & ({ "Baz": { a: number, a2: string, } } | { "Biz": { b: boolean, } } | { "Buz": { c: string, d: number | null, } })"#
+    );
+
+    // And here, they are, again, unecessary
+    assert_eq!(
+        NestedExternallyLonely::inline(),
+        r#"{ "Baz": { a: number, a2: string, } } | { "Biz": { b: boolean, } } | { "Buz": { c: string, d: number | null, } }"#
+    );
+}
+
+#[derive(TS)]
+#[ts(export, export_to = "enum_flattening_nested/adjacently_tagged/")]
+#[cfg_attr(feature = "serde-compat", derive(Serialize))]
+struct FooAdjecently {
+    #[cfg_attr(feature = "serde-compat", serde(flatten))]
+    #[cfg_attr(not(feature = "serde-compat"), ts(flatten))]
+    baz: BarAdjecently,
+}
+
+#[derive(TS)]
+#[ts(export, export_to = "enum_flattening_nested/adjacently_tagged/")]
+#[cfg_attr(feature = "serde-compat", derive(Serialize))]
+#[cfg_attr(feature = "serde-compat", serde(tag = "type", content = "stuff"))]
+#[cfg_attr(not(feature = "serde-compat"), ts(tag = "type", content = "stuff"))]
+enum BarAdjecently {
+    Baz {
+        a: i32,
+        a2: String,
+    },
+    Biz {
+        b: bool,
+    },
+
+    #[cfg_attr(feature = "serde-compat", serde(untagged))]
+    #[cfg_attr(not(feature = "serde-compat"), ts(untagged))]
+    Buz {
+        c: String,
+        d: Option<i32>,
+    },
+}
+
+#[derive(TS)]
+#[cfg_attr(feature = "serde-compat", derive(Serialize))]
+struct NestedAdjecently {
+    #[cfg_attr(feature = "serde-compat", serde(flatten))]
+    #[cfg_attr(not(feature = "serde-compat"), ts(flatten))]
+    a: FooAdjecently,
+    u: u32,
+}
+
+#[derive(TS)]
+#[cfg_attr(feature = "serde-compat", derive(Serialize))]
+#[ts(export, export_to = "enum_flattening_nested/externally_tagged/")]
+struct NestedAdjecentlyLonely {
+    #[cfg_attr(feature = "serde-compat", serde(flatten))]
+    #[cfg_attr(not(feature = "serde-compat"), ts(flatten))]
+    a: FooAdjecently,
+}
+
+#[test]
+fn adjacently_tagged() {
+    assert_eq!(
+        FooAdjecently::inline(),
+        r#"{ "type": "Baz", "stuff": { a: number, a2: string, } } | { "type": "Biz", "stuff": { b: boolean, } } | { c: string, d: number | null, }"#
+    );
+
+    assert_eq!(
+        NestedAdjecently::inline(),
+        r#"{ u: number, } & ({ "type": "Baz", "stuff": { a: number, a2: string, } } | { "type": "Biz", "stuff": { b: boolean, } } | { c: string, d: number | null, })"#
+    );
+
+    assert_eq!(
+        NestedAdjecentlyLonely::inline(),
+        r#"{ "type": "Baz", "stuff": { a: number, a2: string, } } | { "type": "Biz", "stuff": { b: boolean, } } | { c: string, d: number | null, }"#
+    );
+}
+
+#[derive(TS)]
+#[ts(export, export_to = "enum_flattening_nested/internally_tagged/")]
+#[cfg_attr(feature = "serde-compat", derive(Serialize))]
+struct FooInternally {
+    #[cfg_attr(feature = "serde-compat", serde(flatten))]
+    #[cfg_attr(not(feature = "serde-compat"), ts(flatten))]
+    baz: BarInternally,
+}
+
+#[derive(TS)]
+#[ts(export, export_to = "enum_flattening_nested/internally_tagged/")]
+#[cfg_attr(feature = "serde-compat", derive(Serialize))]
+#[cfg_attr(feature = "serde-compat", serde(tag = "type"))]
+#[cfg_attr(not(feature = "serde-compat"), ts(tag = "type"))]
+enum BarInternally {
+    Baz { a: i32, a2: String },
+    Biz { b: bool },
+    Buz { c: String, d: Option<i32> },
+}
+
+#[derive(TS)]
+#[cfg_attr(feature = "serde-compat", derive(Serialize))]
+struct NestedInternally {
+    #[cfg_attr(feature = "serde-compat", serde(flatten))]
+    #[cfg_attr(not(feature = "serde-compat"), ts(flatten))]
+    a: FooInternally,
+    u: u32,
+}
+
+#[derive(TS)]
+#[cfg_attr(feature = "serde-compat", derive(Serialize))]
+#[ts(export, export_to = "enum_flattening_nested/externally_tagged/")]
+struct NestedInternallyLonely {
+    #[cfg_attr(feature = "serde-compat", serde(flatten))]
+    #[cfg_attr(not(feature = "serde-compat"), ts(flatten))]
+    a: FooInternally,
+}
+
+#[test]
+fn internally_tagged() {
+    assert_eq!(
+        FooInternally::inline(),
+        r#"{ "type": "Baz", a: number, a2: string, } | { "type": "Biz", b: boolean, } | { "type": "Buz", c: string, d: number | null, }"#
+    );
+
+    assert_eq!(
+        NestedInternally::inline(),
+        r#"{ u: number, } & ({ "type": "Baz", a: number, a2: string, } | { "type": "Biz", b: boolean, } | { "type": "Buz", c: string, d: number | null, })"#
+    );
+
+    assert_eq!(
+        NestedInternallyLonely::inline(),
+        r#"{ "type": "Baz", a: number, a2: string, } | { "type": "Biz", b: boolean, } | { "type": "Buz", c: string, d: number | null, }"#
+    );
+}
+
+#[derive(TS)]
+#[ts(export, export_to = "enum_flattening_nested/untagged/")]
+#[cfg_attr(feature = "serde-compat", derive(Serialize))]
+struct FooUntagged {
+    #[cfg_attr(feature = "serde-compat", serde(flatten))]
+    #[cfg_attr(not(feature = "serde-compat"), ts(flatten))]
+    baz: BarUntagged,
+}
+
+#[derive(TS)]
+#[ts(export, export_to = "enum_flattening_nested/untagged/")]
+#[cfg_attr(feature = "serde-compat", derive(Serialize))]
+#[cfg_attr(feature = "serde-compat", serde(untagged))]
+#[cfg_attr(not(feature = "serde-compat"), ts(untagged))]
+enum BarUntagged {
+    Baz { a: i32, a2: String },
+    Biz { b: bool },
+    Buz { c: String },
+}
+
+#[derive(TS)]
+#[cfg_attr(feature = "serde-compat", derive(Serialize))]
+struct NestedUntagged {
+    #[cfg_attr(feature = "serde-compat", serde(flatten))]
+    #[cfg_attr(not(feature = "serde-compat"), ts(flatten))]
+    a: FooUntagged,
+    u: u32,
+}
+
+#[derive(TS)]
+#[cfg_attr(feature = "serde-compat", derive(Serialize))]
+#[ts(export, export_to = "enum_flattening_nested/externally_tagged/")]
+struct NestedUntaggedLonely {
+    #[cfg_attr(feature = "serde-compat", serde(flatten))]
+    #[cfg_attr(not(feature = "serde-compat"), ts(flatten))]
+    a: FooUntagged,
+}
+
+#[test]
+fn untagged() {
+    assert_eq!(
+        FooUntagged::inline(),
+        r#"{ a: number, a2: string, } | { b: boolean, } | { c: string, }"#
+    );
+
+    assert_eq!(
+        NestedUntagged::inline(),
+        r#"{ u: number, } & ({ a: number, a2: string, } | { b: boolean, } | { c: string, })"#
+    );
+
+    assert_eq!(
+        NestedUntaggedLonely::inline(),
+        r#"{ a: number, a2: string, } | { b: boolean, } | { c: string, }"#
+    );
+}