Repository: Aleph-Alpha/ts-rs
Pull request #213 (2024-01-29 19:22:05)

Implement `#[ts(optional = nullable)]` 
This PR implements `#[ts(optional = nullable)]`, as discussed in #112.
`#[ts(optional)] Option<T>` still generates `t?: T`, while `#[ts(optional = nullable)]` generates `t?: T | null`.

I plan on following this up at some point with support for the serde attributes outlined in #112.



Patch:
diff --git a/macros/src/attr/field.rs b/macros/src/attr/field.rs
index aab124a..d45e30f 100644
--- a/macros/src/attr/field.rs
+++ b/macros/src/attr/field.rs
@@ -1,18 +1,29 @@
 use syn::{Attribute, Ident, Result};
+use syn::spanned::Spanned;
 
-use super::parse_assign_str;
 use crate::utils::parse_attrs;
 
+use super::parse_assign_str;
+
 #[derive(Default)]
 pub struct FieldAttr {
     pub type_override: Option<String>,
     pub rename: Option<String>,
     pub inline: bool,
     pub skip: bool,
-    pub optional: bool,
+    pub optional: Optional,
     pub flatten: bool,
 }
 
+/// Indicates whether the field is marked with `#[ts(optional)]`.
+/// `#[ts(optional)]` turns an `t: Option<T>` into `t?: T`, while
+/// `#[ts(optional = nullable)]` turns it into `t?: T | null`.
+#[derive(Default)]
+pub struct Optional {
+    pub optional: bool,
+    pub nullable: bool,
+}
+
 #[cfg(feature = "serde-compat")]
 #[derive(Default)]
 pub struct SerdeFieldAttr(FieldAttr);
@@ -36,7 +47,7 @@ impl FieldAttr {
             rename,
             inline,
             skip,
-            optional,
+            optional: Optional { optional, nullable },
             flatten,
         }: FieldAttr,
     ) {
@@ -44,7 +55,10 @@ impl FieldAttr {
         self.type_override = self.type_override.take().or(type_override);
         self.inline = self.inline || inline;
         self.skip = self.skip || skip;
-        self.optional |= optional;
+        self.optional = Optional {
+            optional: self.optional.optional || optional,
+            nullable: self.optional.nullable || nullable
+        };
         self.flatten |= flatten;
     }
 }
@@ -55,7 +69,22 @@ impl_parse! {
         "rename" => out.rename = Some(parse_assign_str(input)?),
         "inline" => out.inline = true,
         "skip" => out.skip = true,
-        "optional" => out.optional = true,
+        "optional" => {
+          use syn::{Token, Error};
+            let nullable = if input.peek(Token![=]) {
+                input.parse::<Token![=]>()?;
+                match Ident::parse(input)?.to_string().as_str() {
+                    "nullable" => true,
+                    other => Err(Error::new(other.span(), "expected 'nullable'"))?
+                }
+            } else {
+                false
+            };
+            out.optional = Optional {
+                optional: true,
+                nullable,
+            }
+        },
         "flatten" => out.flatten = true,
     }
 }
diff --git a/macros/src/types/named.rs b/macros/src/types/named.rs
index 3ec97d0..e3141d0 100644
--- a/macros/src/types/named.rs
+++ b/macros/src/types/named.rs
@@ -9,6 +9,7 @@ use crate::{
     utils::{raw_name_to_ts_field, to_ts_ident},
     DerivedTS,
 };
+use crate::attr::Optional;
 
 pub(crate) fn named(
     attr: &StructAttr,
@@ -92,8 +93,14 @@ fn format_field(
     }
 
     let (ty, optional_annotation) = match optional {
-        true => (extract_option_argument(&field.ty)?, "?"),
-        false => (&field.ty, ""),
+        Optional { optional: true, nullable } => {
+            let inner_type = extract_option_argument(&field.ty)?; // inner type of the optional
+            match nullable {
+                true => (&field.ty, "?"),  // if it's nullable, we keep the original type
+                false => (inner_type, "?"), // if not, we use the Option's inner type
+            }
+        },
+        Optional { optional: false, .. } => (&field.ty, "")
     };
 
     if flatten {
diff --git a/macros/src/types/newtype.rs b/macros/src/types/newtype.rs
index 30b0775..2473071 100644
--- a/macros/src/types/newtype.rs
+++ b/macros/src/types/newtype.rs
@@ -30,7 +30,7 @@ pub(crate) fn newtype(
         flatten,
     } = FieldAttr::from_attrs(&inner.attrs)?;
 
-    match (&rename_inner, skip, optional, flatten) {
+    match (&rename_inner, skip, optional.optional, flatten) {
         (Some(_), ..) => syn_err!("`rename` is not applicable to newtype fields"),
         (_, true, ..) => return super::unit::null(attr, name),
         (_, _, true, ..) => syn_err!("`optional` is not applicable to newtype fields"),
diff --git a/macros/src/types/tuple.rs b/macros/src/types/tuple.rs
index ce64c62..71ae7f0 100644
--- a/macros/src/types/tuple.rs
+++ b/macros/src/types/tuple.rs
@@ -74,7 +74,7 @@ fn format_field(
     if rename.is_some() {
         syn_err!("`rename` is not applicable to tuple structs")
     }
-    if optional {
+    if optional.optional {
         syn_err!("`optional` is not applicable to tuple fields")
     }
     if flatten {
diff --git a/ts-rs/src/lib.rs b/ts-rs/src/lib.rs
index a7d4a89..17b1005 100644
--- a/ts-rs/src/lib.rs
+++ b/ts-rs/src/lib.rs
@@ -227,7 +227,10 @@ mod export;
 ///   Skip this field  
 ///
 /// - `#[ts(optional)]`:  
-///   Indicates the field may be omitted from the serialized struct
+///   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.
+///   If `#[ts(optional = nullable)]` is present, `t?: T | null` is generated.
 ///
 /// - `#[ts(flatten)]`:  
 ///   Flatten this field (only works if the field is a struct)  


Test patch:
diff --git a/ts-rs/tests/optional_field.rs b/ts-rs/tests/optional_field.rs
index b7c1f20..7f10496 100644
--- a/ts-rs/tests/optional_field.rs
+++ b/ts-rs/tests/optional_field.rs
@@ -3,14 +3,73 @@
 use serde::Serialize;
 use ts_rs::TS;
 
-#[derive(Serialize, TS)]
-struct Optional {
-    #[ts(optional)]
-    a: Option<i32>,
-    b: Option<String>,
+#[test]
+fn in_struct() {
+    #[derive(Serialize, TS)]
+    struct Optional {
+        #[ts(optional)]
+        a: Option<i32>,
+        #[ts(optional = nullable)]
+        b: Option<i32>,
+        c: Option<i32>,
+    }
+
+    let a = "a?: number";
+    let b = "b?: number | null";
+    let c = "c: number | null";
+    assert_eq!(Optional::inline(), format!("{{ {a}, {b}, {c}, }}"));
 }
 
 #[test]
-fn test() {
-    assert_eq!(Optional::inline(), "{ a?: number, b: string | null, }");
+fn in_enum() {
+    #[derive(Serialize, TS)]
+    enum Optional {
+        A { #[ts(optional)] a: Option<i32> },
+        B { b: Option<String>, }
+    }
+
+    assert_eq!(Optional::inline(), r#"{ "A": { a?: number, } } | { "B": { b: string | null, } }"#);
+}
+
+#[test]
+fn flatten() {
+    #[derive(Serialize, TS)]
+    struct Optional {
+        #[ts(optional)]
+        a: Option<i32>,
+        #[ts(optional = nullable)]
+        b: Option<i32>,
+        c: Option<i32>,
+    }
+
+    #[derive(Serialize, TS)]
+    struct Flatten {
+        #[ts(flatten)]
+        x: Optional,
+    }
+
+    assert_eq!(Flatten::inline(), Optional::inline());
+}
+
+#[test]
+fn inline() {
+    #[derive(Serialize, TS)]
+    struct Optional {
+        #[ts(optional)]
+        a: Option<i32>,
+        #[ts(optional = nullable)]
+        b: Option<i32>,
+        c: Option<i32>,
+    }
+
+    #[derive(Serialize, TS)]
+    struct Inline {
+        #[ts(inline)]
+        x: Optional,
+    }
+
+    let a = "a?: number";
+    let b = "b?: number | null";
+    let c = "c: number | null";
+    assert_eq!(Inline::inline(), format!("{{ x: {{ {a}, {b}, {c}, }}, }}"));
 }