diff --git a/macros/src/attr/field.rs b/macros/src/attr/field.rs index c7726efc..8994bd66 100644 --- a/macros/src/attr/field.rs +++ b/macros/src/attr/field.rs @@ -4,7 +4,7 @@ use syn::{ TypeSlice, TypeTuple, }; -use super::{parse_assign_from_str, parse_assign_str, Attr, Serde}; +use super::{parse_assign_from_str, parse_assign_str, parse_optional, Attr, Optional, Serde}; use crate::utils::{parse_attrs, parse_docs}; #[derive(Default)] @@ -21,15 +21,6 @@ pub struct FieldAttr { pub using_serde_with: bool, } -/// Indicates whether the field is marked with `#[ts(optional)]`. -/// `#[ts(optional)]` turns an `t: Option` into `t?: T`, while -/// `#[ts(optional = nullable)]` turns it into `t?: T | null`. -#[derive(Default)] -pub struct Optional { - pub optional: bool, - pub nullable: bool, -} - impl FieldAttr { pub fn from_attrs(attrs: &[Attribute]) -> Result { let mut result = parse_attrs::(attrs)?; @@ -64,10 +55,7 @@ impl Attr for FieldAttr { rename: self.rename.or(other.rename), inline: self.inline || other.inline, skip: self.skip || other.skip, - optional: Optional { - optional: self.optional.optional || other.optional.optional, - nullable: self.optional.nullable || other.optional.nullable, - }, + optional: self.optional.or(other.optional), flatten: self.flatten || other.flatten, using_serde_with: self.using_serde_with || other.using_serde_with, @@ -133,7 +121,7 @@ impl Attr for FieldAttr { ); } - if self.optional.optional { + if let Optional::Optional { .. } = self.optional { syn_err_spanned!( field; "`optional` is not compatible with `flatten`" @@ -156,7 +144,7 @@ impl Attr for FieldAttr { ); } - if self.optional.optional { + if let Optional::Optional { .. } = self.optional { syn_err_spanned!( field; "`optional` cannot with tuple struct fields" @@ -175,23 +163,7 @@ impl_parse! { "rename" => out.rename = Some(parse_assign_str(input)?), "inline" => out.inline = true, "skip" => out.skip = true, - "optional" => { - use syn::{Token, Error}; - let nullable = if input.peek(Token![=]) { - input.parse::()?; - let span = input.span(); - match Ident::parse(input)?.to_string().as_str() { - "nullable" => true, - _ => Err(Error::new(span, "expected 'nullable'"))? - } - } else { - false - }; - out.optional = Optional { - optional: true, - nullable, - } - }, + "optional" => out.optional = parse_optional(input)?, "flatten" => out.flatten = true, } } diff --git a/macros/src/attr/mod.rs b/macros/src/attr/mod.rs index 27d12eb9..61debed4 100644 --- a/macros/src/attr/mod.rs +++ b/macros/src/attr/mod.rs @@ -6,7 +6,7 @@ pub use r#struct::*; use syn::{ parse::{Parse, ParseStream}, punctuated::Punctuated, - Error, Lit, Path, Result, Token, WherePredicate, + Error, Ident, Lit, Path, Result, Token, WherePredicate, }; pub use variant::*; @@ -15,6 +15,34 @@ mod field; mod r#struct; mod variant; +/// Indicates whether the field is marked with `#[ts(optional)]`. +/// `#[ts(optional)]` turns an `t: Option` into `t?: T`, while +/// `#[ts(optional = nullable)]` turns it into `t?: T | null`. +#[derive(Default, Clone, Copy)] +pub enum Optional { + Optional { + nullable: bool, + }, + + #[default] + NotOptional, +} + +impl Optional { + pub fn or(self, other: Optional) -> Self { + match (self, other) { + (Self::NotOptional, Self::NotOptional) => Self::NotOptional, + + (Self::Optional { nullable }, Self::NotOptional) + | (Self::NotOptional, Self::Optional { nullable }) => Self::Optional { nullable }, + + (Self::Optional { nullable: a }, Self::Optional { nullable: b }) => { + Self::Optional { nullable: a || b } + } + } + } +} + #[derive(Copy, Clone, Debug)] pub enum Inflection { Lower, @@ -180,3 +208,18 @@ fn parse_bound(input: ParseStream) -> Result> { other => Err(Error::new(other.span(), "expected string")), } } + +fn parse_optional(input: ParseStream) -> Result { + let nullable = if input.peek(Token![=]) { + input.parse::()?; + let span = input.span(); + match Ident::parse(input)?.to_string().as_str() { + "nullable" => true, + _ => Err(Error::new(span, "expected 'nullable'"))?, + } + } else { + false + }; + + Ok(Optional::Optional { nullable }) +} diff --git a/macros/src/attr/struct.rs b/macros/src/attr/struct.rs index fc4d4cbc..80d1e12f 100644 --- a/macros/src/attr/struct.rs +++ b/macros/src/attr/struct.rs @@ -3,8 +3,8 @@ use std::collections::HashMap; use syn::{parse_quote, Attribute, Fields, Ident, Path, Result, Type, WherePredicate}; use super::{ - parse_assign_from_str, parse_assign_inflection, parse_bound, parse_concrete, Attr, - ContainerAttr, Serde, Tagged, + parse_assign_from_str, parse_assign_inflection, parse_bound, parse_concrete, parse_optional, + Attr, ContainerAttr, Optional, Serde, Tagged, }; use crate::{ attr::{parse_assign_str, EnumAttr, Inflection, VariantAttr}, @@ -24,6 +24,7 @@ pub struct StructAttr { pub docs: String, pub concrete: HashMap, pub bound: Option>, + pub optional_fields: Optional, } impl StructAttr { @@ -90,6 +91,7 @@ impl Attr for StructAttr { (Some(bound), None) | (None, Some(bound)) => Some(bound), (None, None) => None, }, + optional_fields: self.optional_fields.or(other.optional_fields), } } @@ -106,6 +108,10 @@ impl Attr for StructAttr { if self.tag.is_some() { syn_err!("`tag` is not compatible with `type`"); } + + if let Optional::Optional { .. } = self.optional_fields { + syn_err!("`optional_fields` is not compatible with `type`"); + } } if self.type_as.is_some() { @@ -116,6 +122,10 @@ impl Attr for StructAttr { if self.rename_all.is_some() { syn_err!("`rename_all` is not compatible with `as`"); } + + if let Optional::Optional { .. } = self.optional_fields { + syn_err!("`optional_fields` is not compatible with `as`"); + } } if !matches!(item, Fields::Named(_)) { @@ -126,6 +136,10 @@ impl Attr for StructAttr { if self.rename_all.is_some() { syn_err!("`rename_all` cannot be used with unit or tuple structs"); } + + if let Optional::Optional { .. } = self.optional_fields { + syn_err!("`optional_fields` cannot be used with unit or tuple structs"); + } } Ok(()) @@ -152,6 +166,7 @@ impl_parse! { "export_to" => out.export_to = Some(parse_assign_str(input)?), "concrete" => out.concrete = parse_concrete(input)?, "bound" => out.bound = Some(parse_bound(input)?), + "optional_fields" => out.optional_fields = parse_optional(input)?, } } diff --git a/macros/src/lib.rs b/macros/src/lib.rs index 4be1ce53..a1ade3ab 100644 --- a/macros/src/lib.rs +++ b/macros/src/lib.rs @@ -80,6 +80,7 @@ impl DerivedTS { quote! { #impl_start { #assoc_type + type OptionInnerType = Self; fn ident() -> String { #ident.to_owned() @@ -156,6 +157,7 @@ impl DerivedTS { } impl #crate_rename::TS for #generics { type WithoutGenerics = #generics; + type OptionInnerType = Self; fn name() -> String { stringify!(#generics).to_owned() } fn inline() -> String { panic!("{} cannot be inlined", #name) } fn inline_flattened() -> String { stringify!(#generics).to_owned() } diff --git a/macros/src/types/enum.rs b/macros/src/types/enum.rs index 95cd38d9..1f43c3d0 100644 --- a/macros/src/types/enum.rs +++ b/macros/src/types/enum.rs @@ -24,7 +24,7 @@ pub(crate) fn r#enum_def(s: &ItemEnum) -> syn::Result { if let Some(attr_type_override) = &enum_attr.type_override { return type_override::type_override_enum(&enum_attr, &name, attr_type_override); } - + if let Some(attr_type_as) = &enum_attr.type_as { return type_as::type_as_enum(&enum_attr, &name, attr_type_as); } diff --git a/macros/src/types/named.rs b/macros/src/types/named.rs index 95a658ea..ba167be5 100644 --- a/macros/src/types/named.rs +++ b/macros/src/types/named.rs @@ -1,15 +1,13 @@ -use proc_macro2::TokenStream; -use quote::quote; -use syn::{ - spanned::Spanned, Field, FieldsNamed, GenericArgument, Path, PathArguments, Result, Type, -}; - use crate::{ attr::{Attr, ContainerAttr, FieldAttr, Inflection, Optional, StructAttr}, deps::Dependencies, utils::{raw_name_to_ts_field, to_ts_ident}, DerivedTS, }; +use proc_macro2::TokenStream; +use quote::{quote, quote_spanned}; +use syn::spanned::Spanned; +use syn::{parse_quote, Field, FieldsNamed, Path, Result}; pub(crate) fn named(attr: &StructAttr, name: &str, fields: &FieldsNamed) -> Result { let crate_rename = attr.crate_rename(); @@ -33,6 +31,7 @@ pub(crate) fn named(attr: &StructAttr, name: &str, fields: &FieldsNamed) -> Resu &mut dependencies, field, &attr.rename_all, + attr.optional_fields, )?; } @@ -93,6 +92,7 @@ fn format_field( dependencies: &mut Dependencies, field: &Field, rename_all: &Option, + struct_optional: Optional, ) -> Result<()> { let field_attr = FieldAttr::from_attrs(&field.attrs)?; @@ -102,27 +102,40 @@ fn format_field( return Ok(()); } - let parsed_ty = field_attr.type_as(&field.ty); - - let (ty, optional_annotation) = match field_attr.optional { - Optional { - optional: true, + let ty = field_attr.type_as(&field.ty); + + let (optional_annotation, nullable) = match (struct_optional, field_attr.optional) { + // `#[ts(optional)]` on field takes precedence, 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`. + quote_spanned! { field.span() => { + fn check_that_field_is_option(_: std::marker::PhantomData) {} + let x: std::marker::PhantomData<#ty> = std::marker::PhantomData; + check_that_field_is_option(x); + "?" + }}, nullable, - } => { - let inner_type = extract_option_argument(&parsed_ty)?; // inner type of the optional - match nullable { - true => (&parsed_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, .. - } => (&parsed_ty, ""), + ), + // `#[ts(optional)]` on the struct acts as `#[ts(optional)]` on a field, but does not error on non-`Option` + // fields. Instead, it is a no-op. + (Optional::Optional { nullable }, _) => ( + quote! { + if <#ty as #crate_rename::TS>::IS_OPTION { "?" } else { "" } + }, + nullable, + ), + _ => (quote!(""), true), + }; + + let ty = if nullable { + ty + } else { + parse_quote! {<#ty as #crate_rename::TS>::OptionInnerType} }; if field_attr.flatten { flattened_fields.push(quote!(<#ty as #crate_rename::TS>::inline_flattened())); - dependencies.append_from(ty); + dependencies.append_from(&ty); return Ok(()); } @@ -131,10 +144,10 @@ fn format_field( .map(|t| quote!(#t)) .unwrap_or_else(|| { if field_attr.inline { - dependencies.append_from(ty); + dependencies.append_from(&ty); quote!(<#ty as #crate_rename::TS>::inline()) } else { - dependencies.push(ty); + dependencies.push(&ty); quote!(<#ty as #crate_rename::TS>::name()) } }); @@ -159,28 +172,3 @@ fn format_field( Ok(()) } - -fn extract_option_argument(ty: &Type) -> Result<&Type> { - match ty { - Type::Path(type_path) - if type_path.qself.is_none() - && type_path.path.leading_colon.is_none() - && type_path.path.segments.len() == 1 - && type_path.path.segments[0].ident == "Option" => - { - let segment = &type_path.path.segments[0]; - match &segment.arguments { - PathArguments::AngleBracketed(args) if args.args.len() == 1 => { - match &args.args[0] { - GenericArgument::Type(inner_ty) => Ok(inner_ty), - other => syn_err!(other.span(); "`Option` argument must be a type"), - } - } - other => { - syn_err!(other.span(); "`Option` type must have a single generic argument") - } - } - } - other => syn_err!(other.span(); "`optional` can only be used on an Option type"), - } -} diff --git a/ts-rs/src/chrono.rs b/ts-rs/src/chrono.rs index 281ec430..ab57d025 100644 --- a/ts-rs/src/chrono.rs +++ b/ts-rs/src/chrono.rs @@ -12,6 +12,8 @@ macro_rules! impl_dummy { ($($t:ty),*) => {$( impl TS for $t { type WithoutGenerics = $t; + type OptionInnerType = Self; + fn name() -> String { String::new() } fn inline() -> String { String::new() } fn inline_flattened() -> String { panic!("{} cannot be flattened", Self::name()) } @@ -26,6 +28,8 @@ impl_dummy!(Utc, Local, FixedOffset); impl TS for DateTime { type WithoutGenerics = Self; + type OptionInnerType = Self; + fn ident() -> String { "string".to_owned() } @@ -48,6 +52,8 @@ impl TS for DateTime { impl TS for Date { type WithoutGenerics = Self; + type OptionInnerType = Self; + fn ident() -> String { "string".to_owned() } diff --git a/ts-rs/src/export.rs b/ts-rs/src/export.rs index 7b0d08fd..3640c96e 100644 --- a/ts-rs/src/export.rs +++ b/ts-rs/src/export.rs @@ -181,9 +181,9 @@ fn export_and_merge( Ok(()) } -const HEADER_ERROR_MESSAGE: &'static str = "The generated strings must have their NOTE and imports separated from their type declarations by a new line"; +const HEADER_ERROR_MESSAGE: &str = "The generated strings must have their NOTE and imports separated from their type declarations by a new line"; -const DECLARATION_START: &'static str = "export type "; +const DECLARATION_START: &str = "export type "; /// Inserts the imports and declaration from the newly generated type /// into the contents of the file, removimg duplicate imports and organazing diff --git a/ts-rs/src/lib.rs b/ts-rs/src/lib.rs index 3d0ad6cd..bbecbb47 100644 --- a/ts-rs/src/lib.rs +++ b/ts-rs/src/lib.rs @@ -380,6 +380,7 @@ pub trait TS { /// struct GenericType(A, B); /// impl TS for GenericType { /// type WithoutGenerics = GenericType; + /// type OptionInnerType = Self; /// // ... /// # fn decl() -> String { todo!() } /// # fn decl_concrete() -> String { todo!() } @@ -390,10 +391,17 @@ pub trait TS { /// ``` type WithoutGenerics: TS + ?Sized; + /// If the implementing type is `std::option::Option`, then this associated type is set to `T`. + /// All other implementations of `TS` should set this type to `Self` instead. + type OptionInnerType: ?Sized; + /// JSDoc comment to describe this type in TypeScript - when `TS` is derived, docs are /// automatically read from your doc comments or `#[doc = ".."]` attributes const DOCS: Option<&'static str> = None; + #[doc(hidden)] + const IS_OPTION: bool = false; + /// Identifier of this type, excluding generic parameters. fn ident() -> String { // by default, fall back to `TS::name()`. @@ -619,11 +627,22 @@ impl Dependency { } } +#[doc(hidden)] +#[diagnostic::on_unimplemented( + message = "`#[ts(optional)]` can only be used on fields of type `Option`", + 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 {} + +impl IsOption for Option {} + // generate impls for primitive types macro_rules! impl_primitives { ($($($ty:ty),* => $l:literal),*) => { $($( impl TS for $ty { type WithoutGenerics = Self; + type OptionInnerType = Self; fn name() -> String { $l.to_owned() } fn inline() -> String { ::name() } fn inline_flattened() -> String { panic!("{} cannot be flattened", ::name()) } @@ -637,6 +656,7 @@ macro_rules! impl_tuples { ( impl $($i:ident),* ) => { impl<$($i: TS),*> TS for ($($i,)*) { type WithoutGenerics = (Dummy, ); + type OptionInnerType = Self; fn name() -> String { format!("[{}]", [$(<$i as $crate::TS>::name()),*].join(", ")) } @@ -669,6 +689,7 @@ macro_rules! impl_wrapper { ($($t:tt)*) => { $($t)* { type WithoutGenerics = Self; + type OptionInnerType = Self; fn name() -> String { T::name() } fn inline() -> String { T::inline() } fn inline_flattened() -> String { T::inline_flattened() } @@ -697,6 +718,7 @@ macro_rules! impl_shadow { (as $s:ty: $($impl:tt)*) => { $($impl)* { type WithoutGenerics = <$s as $crate::TS>::WithoutGenerics; + type OptionInnerType = <$s as $crate::TS>::OptionInnerType; fn ident() -> String { <$s as $crate::TS>::ident() } fn name() -> String { <$s as $crate::TS>::name() } fn inline() -> String { <$s as $crate::TS>::inline() } @@ -722,6 +744,8 @@ macro_rules! impl_shadow { impl TS for Option { type WithoutGenerics = Self; + type OptionInnerType = T; + const IS_OPTION: bool = true; fn name() -> String { format!("{} | null", T::name()) @@ -761,6 +785,7 @@ impl TS for Option { impl TS for Result { type WithoutGenerics = Result; + type OptionInnerType = Self; fn name() -> String { format!("{{ Ok : {} }} | {{ Err : {} }}", T::name(), E::name()) @@ -803,6 +828,7 @@ impl TS for Result { impl TS for Vec { type WithoutGenerics = Vec; + type OptionInnerType = Self; fn ident() -> String { "Array".to_owned() @@ -848,6 +874,8 @@ impl TS for Vec { const ARRAY_TUPLE_LIMIT: usize = 64; impl TS for [T; N] { type WithoutGenerics = [Dummy; N]; + type OptionInnerType = Self; + fn name() -> String { if N > ARRAY_TUPLE_LIMIT { return Vec::::name(); @@ -900,6 +928,7 @@ impl TS for [T; N] { impl TS for HashMap { type WithoutGenerics = HashMap; + type OptionInnerType = Self; fn ident() -> String { panic!() @@ -946,6 +975,8 @@ impl TS for HashMap { impl TS for Range { type WithoutGenerics = Range; + type OptionInnerType = Self; + fn name() -> String { format!("{{ start: {}, end: {}, }}", I::name(), I::name()) } @@ -1078,6 +1109,8 @@ impl std::fmt::Display for Dummy { impl TS for Dummy { type WithoutGenerics = Self; + type OptionInnerType = Self; + fn name() -> String { "Dummy".to_owned() } diff --git a/ts-rs/tests/integration/optional_field.rs b/ts-rs/tests/integration/optional_field.rs index c64cbacf..6db671b7 100644 --- a/ts-rs/tests/integration/optional_field.rs +++ b/ts-rs/tests/integration/optional_field.rs @@ -87,3 +87,31 @@ fn inline() { let c = "c: number | null"; assert_eq!(Inline::inline(), format!("{{ x: {{ {a}, {b}, {c}, }}, }}")); } + +type Foo = Option; +type Bar = Option; + +#[derive(TS)] +#[ts(export, export_to = "optional_field/", optional_fields)] +struct OptionalStruct { + a: Option, + b: Option, + + #[ts(optional = nullable)] + c: Option, + + d: i32, + + e: Foo, + f: Bar, +} + +#[test] +fn struct_optional() { + assert_eq!( + OptionalStruct::inline(), + format!( + "{{ a?: number, b?: number, c?: number | null, d: number, e?: number, f?: number, }}" + ) + ) +}