Repository: Aleph-Alpha/ts-rs
Pull request #180 (2023-11-29 21:14:39)

Added `serde(skip)` functionality inside Enum Variants
## Observation

I wanted to skip serializing an enum error variant in a project, similar to this:

```rust
#[derive(Debug, ::thiserror::Error, serde::Serialize, ts_rs::TS)]
#[ts(export)]
#[serde(tag = "type", content = "content")]
pub enum Error {
    #[error("Account with id ({0}) not found")]
    NotFound(AccountId),
    #[error(transparent)]
    Any(
        #[from]
        #[serde(skip)]
        ::anyhow::Error,
    ),
}
```

This produced the following error: "`skip` is not applicable to newtype fields".

## Problem

I wanted to skip serializing the field, to not send it to the client, as I want to keep `Any` errors on the server and treat them as an anonymous `InternalServerError` to the client.

However, I want to keep the internal error data for logging, I do not want to "delete" the error data.

It seemed like I needed a copy of my `Error` and omit the `Any` variants value (`anyhow::Error`), and then create a `From`/`Into` implementation from the `Error` with the `Any` field into the `Error` without the `Any` field.

## Solution

In this pull request I have modified the `ts-rs` code so that it generates the types in the same way that `serde` serializes Enums in these conditions.
This is achieved by treating an Enum Variant with exactly one Unnamed field (which was already done by the code before in some places) and if that field has a `skip` attribute, then treat that Enum Variant as a Unit instead of a Newtype.

## Tests

I implemented tests to guarantee the behaviour for all 4 types of Enum Tags (Untagged, Externally, Adjacently, Internally) both for `Named` and `Unnamed` field variants.


Patch:
diff --git a/macros/src/types/enum.rs b/macros/src/types/enum.rs
index 043379a..4c73b75 100644
--- a/macros/src/types/enum.rs
+++ b/macros/src/types/enum.rs
@@ -91,18 +91,33 @@ fn format_variant(
         Tagged::Untagged => quote!(#inline_type),
         Tagged::Externally => match &variant.fields {
             Fields::Unit => quote!(format!("\"{}\"", #name)),
+            Fields::Unnamed(unnamed) if unnamed.unnamed.len() == 1 => {
+                let FieldAttr { skip, .. } = FieldAttr::from_attrs(&unnamed.unnamed[0].attrs)?;
+                if skip {
+                    quote!(format!("\"{}\"", #name))
+                } else {
+                    quote!(format!("{{ \"{}\": {} }}", #name, #inline_type))
+                }
+            }
             _ => quote!(format!("{{ \"{}\": {} }}", #name, #inline_type)),
         },
         Tagged::Adjacently { tag, content } => match &variant.fields {
             Fields::Unnamed(unnamed) if unnamed.unnamed.len() == 1 => {
-                let FieldAttr { type_override, .. } =
-                    FieldAttr::from_attrs(&unnamed.unnamed[0].attrs)?;
-                let ty = if let Some(type_override) = type_override {
-                    quote! { #type_override }
+                let FieldAttr {
+                    type_override,
+                    skip,
+                    ..
+                } = FieldAttr::from_attrs(&unnamed.unnamed[0].attrs)?;
+                if skip {
+                    quote!(format!("{{ \"{}\": \"{}\" }}", #tag, #name))
                 } else {
-                    format_type(&unnamed.unnamed[0].ty, dependencies, generics)
-                };
-                quote!(format!("{{ \"{}\": \"{}\", \"{}\": {} }}", #tag, #name, #content, #ty))
+                    let ty = if let Some(type_override) = type_override {
+                        quote! { #type_override }
+                    } else {
+                        format_type(&unnamed.unnamed[0].ty, dependencies, generics)
+                    };
+                    quote!(format!("{{ \"{}\": \"{}\", \"{}\": {} }}", #tag, #name, #content, #ty))
+                }
             }
             Fields::Unit => quote!(format!("{{ \"{}\": \"{}\" }}", #tag, #name)),
             _ => quote!(
@@ -120,14 +135,21 @@ fn format_variant(
             },
             None => match &variant.fields {
                 Fields::Unnamed(unnamed) if unnamed.unnamed.len() == 1 => {
-                    let FieldAttr { type_override, .. } =
-                        FieldAttr::from_attrs(&unnamed.unnamed[0].attrs)?;
-                    let ty = if let Some(type_override) = type_override {
-                        quote! { #type_override }
+                    let FieldAttr {
+                        type_override,
+                        skip,
+                        ..
+                    } = FieldAttr::from_attrs(&unnamed.unnamed[0].attrs)?;
+                    if skip {
+                        quote!(format!("{{ \"{}\": \"{}\" }}", #tag, #name))
                     } else {
-                        format_type(&unnamed.unnamed[0].ty, dependencies, generics)
-                    };
-                    quote!(format!("{{ \"{}\": \"{}\" }} & {}", #tag, #name, #ty))
+                        let ty = if let Some(type_override) = type_override {
+                            quote! { #type_override }
+                        } else {
+                            format_type(&unnamed.unnamed[0].ty, dependencies, generics)
+                        };
+                        quote!(format!("{{ \"{}\": \"{}\" }} & {}", #tag, #name, #ty))
+                    }
                 }
                 Fields::Unit => quote!(format!("{{ \"{}\": \"{}\" }}", #tag, #name)),
                 _ => {
diff --git a/macros/src/types/newtype.rs b/macros/src/types/newtype.rs
index b5070d7..30b0775 100644
--- a/macros/src/types/newtype.rs
+++ b/macros/src/types/newtype.rs
@@ -32,7 +32,7 @@ pub(crate) fn newtype(
 
     match (&rename_inner, skip, optional, flatten) {
         (Some(_), ..) => syn_err!("`rename` is not applicable to newtype fields"),
-        (_, true, ..) => syn_err!("`skip` is not applicable to newtype fields"),
+        (_, true, ..) => return super::unit::null(attr, name),
         (_, _, true, ..) => syn_err!("`optional` is not applicable to newtype fields"),
         (_, _, _, true) => syn_err!("`flatten` is not applicable to newtype fields"),
         _ => {}


Test patch:
diff --git a/ts-rs/tests/union_named_serde_skip.rs b/ts-rs/tests/union_named_serde_skip.rs
new file mode 100644
index 0000000..804e058
--- /dev/null
+++ b/ts-rs/tests/union_named_serde_skip.rs
@@ -0,0 +1,71 @@
+#![allow(dead_code)]
+
+use serde::Deserialize;
+use ts_rs::TS;
+
+#[derive(TS, Deserialize)]
+#[serde(untagged)]
+enum TestUntagged {
+    A,   // serde_json -> `null`
+    B(), // serde_json -> `[]`
+    C {
+        #[serde(skip)]
+        val: i32,
+    }, // serde_json -> `{}`
+}
+
+#[derive(TS, Deserialize)]
+enum TestExternally {
+    A,   // serde_json -> `"A"`
+    B(), // serde_json -> `{"B":[]}`
+    C {
+        #[serde(skip)]
+        val: i32,
+    }, // serde_json -> `{"C":{}}`
+}
+
+#[derive(TS, Deserialize)]
+#[serde(tag = "type", content = "content")]
+enum TestAdjacently {
+    A,   // serde_json -> `{"type":"A"}`
+    B(), // serde_json -> `{"type":"B","content":[]}`
+    C {
+        #[serde(skip)]
+        val: i32,
+    }, // serde_json -> `{"type":"C","content":{}}`
+}
+
+#[derive(TS, Deserialize)]
+#[serde(tag = "type")]
+enum TestInternally {
+    A, // serde_json -> `{"type":"A"}`
+    B, // serde_json -> `{"type":"B"}`
+    C {
+        #[serde(skip)]
+        val: i32,
+    }, // serde_json -> `{"type":"C"}`
+}
+
+#[cfg(feature = "serde-compat")]
+#[test]
+fn test() {
+    assert_eq!(
+        TestUntagged::decl(),
+        r#"type TestUntagged = null | never[] | {  };"#
+    );
+
+    assert_eq!(
+        TestExternally::decl(),
+        r#"type TestExternally = "A" | { "B": never[] } | { "C": {  } };"#
+    );
+
+    assert_eq!(
+        TestAdjacently::decl(),
+        r#"type TestAdjacently = { "type": "A" } | { "type": "B", "content": never[] } | { "type": "C", "content": {  } };"#
+    );
+
+    assert_eq!(
+        TestInternally::decl(),
+        r#"type TestInternally = { "type": "A" } | { "type": "B" } | { "type": "C",  };"#
+    );
+}
diff --git a/ts-rs/tests/union_unnamed_serde_skip.rs b/ts-rs/tests/union_unnamed_serde_skip.rs
new file mode 100644
index 0000000..1dfae1c
--- /dev/null
+++ b/ts-rs/tests/union_unnamed_serde_skip.rs
@@ -0,0 +1,59 @@
+#![allow(dead_code)]
+
+use serde::Deserialize;
+use ts_rs::TS;
+
+#[derive(TS, Deserialize)]
+#[serde(untagged)]
+enum TestUntagged {
+    A,                     // serde_json -> `null`
+    B(),                   // serde_json -> `[]`
+    C(#[serde(skip)] i32), // serde_json -> `null`
+}
+
+#[derive(TS, Deserialize)]
+enum TestExternally {
+    A,                     // serde_json -> `"A"`
+    B(),                   // serde_json -> `{"B":[]}`
+    C(#[serde(skip)] i32), // serde_json -> `"C"`
+}
+
+#[derive(TS, Deserialize)]
+#[serde(tag = "type", content = "content")]
+enum TestAdjacently {
+    A,                     // serde_json -> `{"type":"A"}`
+    B(),                   // serde_json -> `{"type":"B","content":[]}`
+    C(#[serde(skip)] i32), // serde_json -> `{"type":"C"}`
+}
+
+#[derive(TS, Deserialize)]
+#[serde(tag = "type")]
+enum TestInternally {
+    A,                     // serde_json -> `{"type":"A"}`
+    B,                     // serde_json -> `{"type":"B"}`
+    C(#[serde(skip)] i32), // serde_json -> `{"type":"C"}`
+}
+
+#[cfg(feature = "serde-compat")]
+#[test]
+fn test() {
+    assert_eq!(
+        TestUntagged::decl(),
+        r#"type TestUntagged = null | never[] | null;"#
+    );
+
+    assert_eq!(
+        TestExternally::decl(),
+        r#"type TestExternally = "A" | { "B": never[] } | "C";"#
+    );
+
+    assert_eq!(
+        TestAdjacently::decl(),
+        r#"type TestAdjacently = { "type": "A" } | { "type": "B", "content": never[] } | { "type": "C" };"#
+    );
+
+    assert_eq!(
+        TestInternally::decl(),
+        r#"type TestInternally = { "type": "A" } | { "type": "B" } | { "type": "C" };"#
+    );
+}