From c24ae3704c4da227bf6495e69461d0e32c19a4e8 Mon Sep 17 00:00:00 2001 From: gustavo-shigueo <58121396+gustavo-shigueo@users.noreply.github.com> Date: Sat, 25 Oct 2025 20:31:47 -0300 Subject: [PATCH 1/7] Add test for generic optional --- ts-rs/tests/integration/optional_field.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/ts-rs/tests/integration/optional_field.rs b/ts-rs/tests/integration/optional_field.rs index 6d8ad200..4b3b2b05 100644 --- a/ts-rs/tests/integration/optional_field.rs +++ b/ts-rs/tests/integration/optional_field.rs @@ -21,6 +21,27 @@ fn in_struct() { assert_eq!(OptionalInStruct::inline(), format!("{{ {a}, {b}, {c}, }}")); } +#[derive(Serialize, TS)] +#[ts(export, export_to = "optional_field/")] +struct GenericOptionalStruct { + #[ts(optional)] + a: Option, + #[ts(optional = nullable)] + b: Option, + c: Option, + d: T, + e: Option, + f: i32, +} + +#[test] +fn in_generic_struct() { + assert_eq!( + GenericOptionalStruct::<()>::decl(), + "type GenericOptionalStruct = { a?: T, b?: T | null, c: T | null, d: T, e: number | null, f: number, };" + ) +} + #[derive(Serialize, TS)] #[ts(export, export_to = "optional_field/")] enum OptionalInEnum { From 6bee012e140fecb39bf6f3453dbda8d320db863d Mon Sep 17 00:00:00 2001 From: gustavo-shigueo <58121396+gustavo-shigueo@users.noreply.github.com> Date: Sat, 25 Oct 2025 20:32:38 -0300 Subject: [PATCH 2/7] Fix dependecy tracking --- macros/src/types/named.rs | 11 ++++++++--- macros/src/types/tuple.rs | 10 ++++++++-- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/macros/src/types/named.rs b/macros/src/types/named.rs index 47ada7cf..56322586 100644 --- a/macros/src/types/named.rs +++ b/macros/src/types/named.rs @@ -105,6 +105,14 @@ fn format_field( let ty = field_attr.type_as(&field.ty); + if field_attr.type_override.is_none() { + if field_attr.inline || field_attr.flatten { + dependencies.append_from(&ty); + } else { + dependencies.push(&ty); + } + } + let (is_optional, ty) = crate::optional::apply( crate_rename, struct_optional, @@ -116,7 +124,6 @@ fn format_field( if field_attr.flatten { flattened_fields.push(quote!(<#ty as #crate_rename::TS>::inline_flattened())); - dependencies.append_from(&ty); return Ok(()); } @@ -125,10 +132,8 @@ fn format_field( .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()) } }); diff --git a/macros/src/types/tuple.rs b/macros/src/types/tuple.rs index d2919a9f..223bc31e 100644 --- a/macros/src/types/tuple.rs +++ b/macros/src/types/tuple.rs @@ -58,6 +58,14 @@ fn format_field( } let ty = field_attr.type_as(&field.ty); + if field_attr.type_override.is_none() { + if field_attr.inline { + dependencies.append_from(&ty); + } else { + dependencies.push(&ty); + } + } + let (is_optional, ty) = crate::optional::apply( crate_rename, struct_optional, @@ -71,10 +79,8 @@ fn format_field( .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()) } }); From 4bc7b4790f43dac9e7bb4cc974e5af0a6aa88dd3 Mon Sep 17 00:00:00 2001 From: gustavo-shigueo <58121396+gustavo-shigueo@users.noreply.github.com> Date: Sat, 25 Oct 2025 20:33:16 -0300 Subject: [PATCH 3/7] Simplify compile-time enforcing of Option --- macros/src/optional.rs | 18 ++++++++---------- ts-rs/src/lib.rs | 8 ++++++-- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/macros/src/optional.rs b/macros/src/optional.rs index 558290ec..c7b3b3e9 100644 --- a/macros/src/optional.rs +++ b/macros/src/optional.rs @@ -75,16 +75,14 @@ pub fn apply( // explicit `#[ts(optional)]` on field. // It takes precedence over the struct attribute, and is enforced **AT COMPILE TIME** (_, Optional::Optional { nullable }) => ( - // expression that evaluates to the string "?", but fails to compile if `ty` is not an `Option`. - parse_quote_spanned! { span => { - fn check_that_field_is_option(_: std::marker::PhantomData) {} - let x: std::marker::PhantomData<#field_ty> = std::marker::PhantomData; - check_that_field_is_option(x); - true - }}, - nullable - .then(|| field_ty.clone()) - .unwrap_or_else(|| unwrap_option(crate_rename, field_ty)), + parse_quote!(true), + nullable.then(|| field_ty.clone()).unwrap_or_else(|| { + // expression that evaluates to the the Option's inner type, + // but fails to compile if `field_ty` is not an `Option`. + parse_quote_spanned! { + span => <#field_ty as #crate_rename::IsOption>::Inner + } + }), ), // Inherited `#[ts(optional)]` from the struct. // Acts like `#[ts(optional)]` on a field, but does not error on non-`Option` fields. diff --git a/ts-rs/src/lib.rs b/ts-rs/src/lib.rs index 08b90ff3..06fb42d8 100644 --- a/ts-rs/src/lib.rs +++ b/ts-rs/src/lib.rs @@ -666,9 +666,13 @@ impl Dependency { note = "`#[ts(optional)]` was used on a field of type {Self}, which is not permitted", label = "`#[ts(optional)]` is not allowed on field of type {Self}" )] -pub trait IsOption {} +pub trait IsOption { + type Inner; +} -impl IsOption for Option {} +impl IsOption for Option { + type Inner = T; +} // generate impls for primitive types macro_rules! impl_primitives { From d2213fc8b7ccae7bdb93f41b0f4c4967818d3600 Mon Sep 17 00:00:00 2001 From: gustavo-shigueo <58121396+gustavo-shigueo@users.noreply.github.com> Date: Sat, 25 Oct 2025 20:52:14 -0300 Subject: [PATCH 4/7] Update CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 947be9e3..0d4150d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixes - Do not emit warning for `#[serde(crate = "..")]` ([#447](https://github.com/Aleph-Alpha/ts-rs/pull/447)) +- Fix trait bound generation when using `#[ts(optional)]` on an `Option` ([#454](https://github.com/Aleph-Alpha/ts-rs/pull/454)) # 11.1.0 ### Features From cb694808353e732ce372054dc7007c0ace836878 Mon Sep 17 00:00:00 2001 From: gustavo-shigueo <58121396+gustavo-shigueo@users.noreply.github.com> Date: Sat, 25 Oct 2025 21:16:19 -0300 Subject: [PATCH 5/7] Remove extra fields from test to make sure it actually tests the problem --- ts-rs/tests/integration/optional_field.rs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/ts-rs/tests/integration/optional_field.rs b/ts-rs/tests/integration/optional_field.rs index 4b3b2b05..ff7791fa 100644 --- a/ts-rs/tests/integration/optional_field.rs +++ b/ts-rs/tests/integration/optional_field.rs @@ -26,19 +26,13 @@ fn in_struct() { struct GenericOptionalStruct { #[ts(optional)] a: Option, - #[ts(optional = nullable)] - b: Option, - c: Option, - d: T, - e: Option, - f: i32, } #[test] fn in_generic_struct() { assert_eq!( GenericOptionalStruct::<()>::decl(), - "type GenericOptionalStruct = { a?: T, b?: T | null, c: T | null, d: T, e: number | null, f: number, };" + "type GenericOptionalStruct = { a?: T, };" ) } From 8a9e50c77cbd584b83a1a3c96ded35a7f8c65430 Mon Sep 17 00:00:00 2001 From: gustavo-shigueo <58121396+gustavo-shigueo@users.noreply.github.com> Date: Sun, 26 Oct 2025 17:59:11 -0300 Subject: [PATCH 6/7] Go back to applying optional before adding to dependencies --- macros/src/types/named.rs | 16 ++++++++-------- macros/src/types/tuple.rs | 16 ++++++++-------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/macros/src/types/named.rs b/macros/src/types/named.rs index 56322586..a41012d7 100644 --- a/macros/src/types/named.rs +++ b/macros/src/types/named.rs @@ -105,14 +105,6 @@ fn format_field( let ty = field_attr.type_as(&field.ty); - if field_attr.type_override.is_none() { - if field_attr.inline || field_attr.flatten { - dependencies.append_from(&ty); - } else { - dependencies.push(&ty); - } - } - let (is_optional, ty) = crate::optional::apply( crate_rename, struct_optional, @@ -122,6 +114,14 @@ fn format_field( ); let optional_annotation = quote!(if #is_optional { "?" } else { "" }); + if field_attr.type_override.is_none() { + if field_attr.inline || field_attr.flatten { + dependencies.append_from(&ty); + } else { + dependencies.push(&ty); + } + } + if field_attr.flatten { flattened_fields.push(quote!(<#ty as #crate_rename::TS>::inline_flattened())); return Ok(()); diff --git a/macros/src/types/tuple.rs b/macros/src/types/tuple.rs index 223bc31e..41cfb0a6 100644 --- a/macros/src/types/tuple.rs +++ b/macros/src/types/tuple.rs @@ -58,14 +58,6 @@ fn format_field( } let ty = field_attr.type_as(&field.ty); - if field_attr.type_override.is_none() { - if field_attr.inline { - dependencies.append_from(&ty); - } else { - dependencies.push(&ty); - } - } - let (is_optional, ty) = crate::optional::apply( crate_rename, struct_optional, @@ -74,6 +66,14 @@ fn format_field( field.span(), ); + if field_attr.type_override.is_none() { + if field_attr.inline { + dependencies.append_from(&ty); + } else { + dependencies.push(&ty); + } + } + let formatted_ty = field_attr .type_override .map(|t| quote!(#t.to_owned())) From 521882ad84a21149a54673187c69e001447b9e9c Mon Sep 17 00:00:00 2001 From: gustavo-shigueo <58121396+gustavo-shigueo@users.noreply.github.com> Date: Sun, 26 Oct 2025 17:59:21 -0300 Subject: [PATCH 7/7] Handle qualified types --- macros/src/lib.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/macros/src/lib.rs b/macros/src/lib.rs index 85c9e91c..7a351bdd 100644 --- a/macros/src/lib.rs +++ b/macros/src/lib.rs @@ -7,7 +7,7 @@ use proc_macro2::{Ident, TokenStream}; use quote::{format_ident, quote}; use syn::{ parse_quote, spanned::Spanned, ConstParam, Expr, GenericParam, Generics, Item, LifetimeParam, - Path, Result, Type, TypeArray, TypeParam, TypeParen, TypePath, TypeReference, TypeSlice, + Path, QSelf, Result, Type, TypeArray, TypeParam, TypeParen, TypePath, TypeReference, TypeSlice, TypeTuple, WhereClause, WherePredicate, }; @@ -486,6 +486,12 @@ fn used_type_params<'ty, 'out>( } } } + Type::Path(TypePath { + qself: Some(QSelf { ty, .. }), + .. + }) => { + used_type_params(out, ty, is_type_param); + } _ => (), } }