Repository: Aleph-Alpha/ts-rs
Pull request #416 (2025-06-04 06:36:20)

Regression: `#[ts(optional)]` with `#[ts(type)]`
## Goal
#366 introduced a regression not caught by our tests.  
Before, it was possible to use `#[ts(optional)]` together with `#[ts(type)]`.  This overwrote the type, but made the field optional.  
#366 changed this behavior by emitting a compiler error.  

Closes #414, #415 

## Changes
- Remove compiler error when the two attributes are used together
- Remove the early-returns when a `#[ts(type)]` is present for `types::named` and `types::tuple`.  
  Instead, the flow of `format_field` is as before #366 landed. The `#[ts(optional)]` attribute is processed as usual, and the type is substituted for that of `#[ts(type)]` at the very end only. Like before, this retains the `?`, but honors the type override
- Tweak `optional::apply`
  Since then, we've also added the struct-level `#[ts(optional_fields)]`. I have tweaked `apply` to not inherit `#[ts(optional_fields)]` when `#[ts(type)]` is present. 
  This is not strictly necessary, but I think this suspect be the correct behavior? 

## 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.



Patch:
diff --git a/macros/src/attr/field.rs b/macros/src/attr/field.rs
index 54a5693..a0379fc 100644
--- a/macros/src/attr/field.rs
+++ b/macros/src/attr/field.rs
@@ -106,13 +106,6 @@ impl Attr for FieldAttr {
                     "`type` is not compatible with `flatten`"
                 );
             }
-
-            if let Optional::Optional { .. } = self.optional {
-                syn_err_spanned!(
-                    field;
-                    "`type` is not compatible with `optional`"
-                );
-            }
         }
 
         if self.flatten {
diff --git a/macros/src/optional.rs b/macros/src/optional.rs
index af7fe48..558290e 100644
--- a/macros/src/optional.rs
+++ b/macros/src/optional.rs
@@ -64,10 +64,10 @@ pub fn apply(
     crate_rename: &Path,
     for_struct: Optional,
     field_ty: &Type,
-    field_attr: &FieldAttr,
+    attr: &FieldAttr,
     span: Span,
 ) -> (Expr, Type) {
-    match (for_struct, field_attr.optional) {
+    match (for_struct, attr.optional) {
         // explicit `#[ts(optional = false)]` on field, or inherited from struct.
         (Optional::NotOptional, Optional::Inherit) | (_, Optional::NotOptional) => {
             (parse_quote!(false), field_ty.clone())
@@ -89,7 +89,7 @@ pub fn apply(
         // Inherited `#[ts(optional)]` from the struct.
         // Acts like `#[ts(optional)]` on a field, but does not error on non-`Option` fields.
         // Instead, it is a no-op.
-        (Optional::Optional { nullable }, Optional::Inherit) => (
+        (Optional::Optional { nullable }, Optional::Inherit) if attr.type_override.is_none() => (
             parse_quote! {
                 <#field_ty as #crate_rename::TS>::IS_OPTION
             },
@@ -97,10 +97,11 @@ pub fn apply(
                 .then(|| field_ty.clone())
                 .unwrap_or_else(|| unwrap_option(crate_rename, field_ty)),
         ),
-        // field may be omitted during serialization and has a default value, so the field can be
-        // treated as `#[ts(optional = nullable)]`.
-        (Optional::Inherit, Optional::Inherit) => {
-            let is_optional = field_attr.maybe_omitted && field_attr.has_default;
+        // no applicable `#[ts(optional)]` attributes
+        _ => {
+            // field may be omitted during serialization and has a default value, so the field can be
+            // treated as `#[ts(optional = nullable)]`.
+            let is_optional = attr.maybe_omitted && attr.has_default;
             (parse_quote!(#is_optional), field_ty.clone())
         }
     }
diff --git a/macros/src/types/named.rs b/macros/src/types/named.rs
index 2406f0f..9d26564 100644
--- a/macros/src/types/named.rs
+++ b/macros/src/types/named.rs
@@ -102,28 +102,6 @@ fn format_field(
         return Ok(());
     }
 
-    if let Some(ref type_override) = field_attr.type_override {
-        let field_name = to_ts_ident(field.ident.as_ref().unwrap());
-        let name = match (field_attr.rename.as_ref(), rename_all) {
-            (Some(rn), _) => rn.to_owned(),
-            (None, Some(rn)) => rn.apply(&field_name),
-            (None, None) => field_name,
-        };
-        let valid_name = raw_name_to_ts_field(name);
-
-        // Start every doc string with a newline, because when other characters are in front, it is not "understood" by VSCode
-        let docs = match &*field_attr.docs {
-            &[] => quote!(""),
-            docs => quote!(format!("\n{}", #crate_rename::format_docs(&[#(#docs),*]))),
-        };
-
-        formatted_fields.push(quote! {
-            format!("{}{}: {},", #docs, #valid_name, #type_override)
-        });
-
-        return Ok(());
-    }
-
     let ty = field_attr.type_as(&field.ty);
 
     let (is_optional, ty) = crate::optional::apply(
@@ -141,13 +119,18 @@ fn format_field(
         return Ok(());
     }
 
-    let formatted_ty = if field_attr.inline {
-        dependencies.append_from(&ty);
-        quote!(<#ty as #crate_rename::TS>::inline())
-    } else {
-        dependencies.push(&ty);
-        quote!(<#ty as #crate_rename::TS>::name())
-    };
+    let formatted_ty = field_attr
+        .type_override
+        .map(|t| quote!(#t))
+        .unwrap_or_else(|| {
+            if field_attr.inline {
+                dependencies.append_from(&ty);
+                quote!(<#ty as #crate_rename::TS>::inline())
+            } else {
+                dependencies.push(&ty);
+                quote!(<#ty as #crate_rename::TS>::name())
+            }
+        });
 
     let field_name = to_ts_ident(field.ident.as_ref().unwrap());
     let name = match (field_attr.rename, rename_all) {
diff --git a/macros/src/types/tuple.rs b/macros/src/types/tuple.rs
index 125ca94..27cca5d 100644
--- a/macros/src/types/tuple.rs
+++ b/macros/src/types/tuple.rs
@@ -56,11 +56,6 @@ fn format_field(
         return Ok(());
     }
 
-    if let Some(ref type_override) = field_attr.type_override {
-        formatted_fields.push(quote!(#type_override.to_owned()));
-        return Ok(());
-    }
-
     let ty = field_attr.type_as(&field.ty);
     let (is_optional, ty) = crate::optional::apply(
         crate_rename,
@@ -70,13 +65,18 @@ fn format_field(
         field.span(),
     );
 
-    let formatted_ty = if field_attr.inline {
-        dependencies.append_from(&ty);
-        quote!(<#ty as #crate_rename::TS>::inline())
-    } else {
-        dependencies.push(&ty);
-        quote!(<#ty as #crate_rename::TS>::name())
-    };
+    let formatted_ty = field_attr
+        .type_override
+        .map(|t| quote!(#t.to_owned()))
+        .unwrap_or_else(|| {
+            if field_attr.inline {
+                dependencies.append_from(&ty);
+                quote!(<#ty as #crate_rename::TS>::inline())
+            } else {
+                dependencies.push(&ty);
+                quote!(<#ty as #crate_rename::TS>::name())
+            }
+        });
 
     formatted_fields.push(quote! {
         if #is_optional {


Test patch:
diff --git a/ts-rs/tests/integration/issue_415.rs b/ts-rs/tests/integration/issue_415.rs
new file mode 100644
index 0000000..f2de16b
--- /dev/null
+++ b/ts-rs/tests/integration/issue_415.rs
@@ -0,0 +1,26 @@
+#![cfg(feature = "serde-compat")]
+
+use ts_rs::TS;
+
+struct Foreign;
+
+#[derive(TS)]
+#[ts(export, export_to = "issue_415/")]
+struct Issue415 {
+    #[ts(optional, type = "Date")]
+    a: Option<Foreign>,
+}
+
+#[test]
+fn issue_415() {
+    assert_eq!(Issue415::decl(), "type Issue415 = { a?: Date, };");
+}
+
+#[derive(TS)]
+#[ts(export, export_to = "issue_415/")]
+struct InTuple(i32, #[ts(optional, type = "Date")] Option<Foreign>);
+
+#[test]
+fn in_tuple() {
+    assert_eq!(InTuple::decl(), "type InTuple = [number, (Date)?];");
+}
diff --git a/ts-rs/tests/integration/main.rs b/ts-rs/tests/integration/main.rs
index af3180e..f5d2e92 100644
--- a/ts-rs/tests/integration/main.rs
+++ b/ts-rs/tests/integration/main.rs
@@ -31,6 +31,7 @@ mod issue_308;
 mod issue_317;
 mod issue_338;
 mod issue_397;
+mod issue_415;
 mod issue_70;
 mod issue_80;
 mod leading_colon;