Repository: Aleph-Alpha/ts-rs
Pull request #270 (2024-03-14 19:44:41)

Release v8.0.0
## Goal

Let's get v8.0.0 released! 🚀 

## Changes

After the discussion in #251, I've now already bumped the version here.  
Aditionally, I've relaxed some dependencies:

- `heapless`
  We were supporting `0.7..=0.8`. I've relaxed this to `0.7..0.9`, since e.g `0.8.1` shouldn't introduce a breaking change
- `bigdecimal`
  We were supporting `0.0.13..=0.4`. I've relaxed this to `0.0.13..0.5`, for the same reason as above.
- `ordered-float`
  Same goes for `ordered-float`. Since that's beyond `0.x.x`, and we support `4.0`, we should also support `4.*.*`, so `<5`

I hope I didn't screw anything up here, semver always confuses me.

## Further ToDos
- [x] Get `#[ts(bound)]` merged
  Since it's a very straight-forward feature, i think we can get this in before release. It also makes a lot of sense to release it together with `#[ts(concrete)]` imho.

## Checklist

- [x] I have followed the steps listed in the [Contributing guide](https://github.com/Aleph-Alpha/ts-rs/blob/main/CHANGELOG.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/README.md b/README.md
index 8f53e13..38cef8c 100644
--- a/README.md
+++ b/README.md
@@ -42,7 +42,7 @@ We recommend doing this in your tests.
 ### get started
 ```toml
 [dependencies]
-ts-rs = "7.1"
+ts-rs = "8.0"
 ```
 
 ```rust
diff --git a/macros/Cargo.toml b/macros/Cargo.toml
index 9897b67..a6cb27e 100644
--- a/macros/Cargo.toml
+++ b/macros/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "ts-rs-macros"
-version = "7.1.1"
+version = "8.0.0"
 authors = ["Moritz Bischof <moritz.bischof1@gmail.com>"]
 edition = "2021"
 description = "derive macro for ts-rs"
diff --git a/macros/src/attr/enum.rs b/macros/src/attr/enum.rs
index 9db5895..232d61f 100644
--- a/macros/src/attr/enum.rs
+++ b/macros/src/attr/enum.rs
@@ -1,12 +1,14 @@
 use std::collections::HashMap;
 
-use syn::{Attribute, Ident, Result, Type};
+use syn::{Attribute, Ident, Result, Type, WherePredicate};
 
 use crate::{
     attr::{parse_assign_inflection, parse_assign_str, parse_concrete, Inflection},
     utils::{parse_attrs, parse_docs},
 };
 
+use super::parse_bound;
+
 #[derive(Default)]
 pub struct EnumAttr {
     pub rename_all: Option<Inflection>,
@@ -16,6 +18,7 @@ pub struct EnumAttr {
     pub export: bool,
     pub docs: String,
     pub concrete: HashMap<Ident, Type>,
+    pub bound: Option<Vec<WherePredicate>>,
     tag: Option<String>,
     untagged: bool,
     content: Option<String>,
@@ -71,6 +74,7 @@ impl EnumAttr {
             export,
             docs,
             concrete,
+            bound,
         }: EnumAttr,
     ) {
         self.rename = self.rename.take().or(rename);
@@ -83,6 +87,10 @@ impl EnumAttr {
         self.export_to = self.export_to.take().or(export_to);
         self.docs = docs;
         self.concrete.extend(concrete);
+        self.bound = self.bound
+            .take()
+            .map(|b| b.into_iter().chain(bound.clone().unwrap_or_default()).collect())
+            .or(bound);
     }
 }
 
@@ -97,6 +105,7 @@ impl_parse! {
         "content" => out.content = Some(parse_assign_str(input)?),
         "untagged" => out.untagged = true,
         "concrete" => out.concrete = parse_concrete(input)?,
+        "bound" => out.bound = Some(parse_bound(input)?),
     }
 }
 
@@ -108,6 +117,7 @@ impl_parse! {
         "rename_all_fields" => out.0.rename_all_fields = Some(parse_assign_inflection(input)?),
         "tag" => out.0.tag = Some(parse_assign_str(input)?),
         "content" => out.0.content = Some(parse_assign_str(input)?),
-        "untagged" => out.0.untagged = true
+        "untagged" => out.0.untagged = true,
+        "bound" => out.0.bound = Some(parse_bound(input)?),
     }
 }
diff --git a/macros/src/attr/field.rs b/macros/src/attr/field.rs
index b4440a4..7345eb4 100644
--- a/macros/src/attr/field.rs
+++ b/macros/src/attr/field.rs
@@ -1,11 +1,11 @@
-use syn::{Attribute, Ident, Path, Result};
+use syn::{Attribute, Ident, Result, Type};
 
 use super::{parse_assign_from_str, parse_assign_str};
 use crate::utils::{parse_attrs, parse_docs};
 
 #[derive(Default)]
 pub struct FieldAttr {
-    pub type_as: Option<Path>,
+    pub type_as: Option<Type>,
     pub type_override: Option<String>,
     pub rename: Option<String>,
     pub inline: bool,
diff --git a/macros/src/attr/mod.rs b/macros/src/attr/mod.rs
index e5b4c49..fcf9fbd 100644
--- a/macros/src/attr/mod.rs
+++ b/macros/src/attr/mod.rs
@@ -5,7 +5,7 @@ pub use r#enum::*;
 pub use r#struct::*;
 use syn::{
     parse::{Parse, ParseStream},
-    Error, Lit, Result, Token,
+    Error, Lit, Result, Token, WherePredicate, punctuated::Punctuated,
 };
 pub use variant::*;
 
@@ -107,7 +107,7 @@ fn parse_concrete(input: ParseStream) -> Result<HashMap<syn::Ident, syn::Type>>
     syn::parenthesized!(content in input);
 
     Ok(
-        syn::punctuated::Punctuated::<Concrete, Token![,]>::parse_terminated(&content)?
+        Punctuated::<Concrete, Token![,]>::parse_terminated(&content)?
             .into_iter()
             .map(|concrete| (concrete.ident, concrete.ty))
             .collect(),
@@ -128,3 +128,15 @@ where
         other => Err(Error::new(other.span(), "expected string")),
     }
 }
+
+fn parse_bound(input: ParseStream) -> Result<Vec<WherePredicate>> {
+    input.parse::<Token![=]>()?;
+    match Lit::parse(input)? {
+        Lit::Str(string) => {
+            let parser = Punctuated::<WherePredicate, Token![,]>::parse_terminated;
+
+            Ok(string.parse_with(parser)?.into_iter().collect())
+        },
+        other => Err(Error::new(other.span(), "expected string")),
+    }
+}
diff --git a/macros/src/attr/struct.rs b/macros/src/attr/struct.rs
index 49cc4b2..6ed3bf9 100644
--- a/macros/src/attr/struct.rs
+++ b/macros/src/attr/struct.rs
@@ -1,12 +1,14 @@
 use std::{collections::HashMap, convert::TryFrom};
 
-use syn::{Attribute, Ident, Result, Type};
+use syn::{Attribute, Ident, Result, Type, WherePredicate};
 
 use crate::{
     attr::{parse_assign_str, parse_concrete, Inflection, VariantAttr},
     utils::{parse_attrs, parse_docs},
 };
 
+use super::parse_bound;
+
 #[derive(Default, Clone)]
 pub struct StructAttr {
     pub rename_all: Option<Inflection>,
@@ -16,6 +18,7 @@ pub struct StructAttr {
     pub tag: Option<String>,
     pub docs: String,
     pub concrete: HashMap<Ident, Type>,
+    pub bound: Option<Vec<WherePredicate>>,
 }
 
 #[cfg(feature = "serde-compat")]
@@ -45,6 +48,7 @@ impl StructAttr {
             tag,
             docs,
             concrete,
+            bound,
         }: StructAttr,
     ) {
         self.rename = self.rename.take().or(rename);
@@ -54,6 +58,10 @@ impl StructAttr {
         self.tag = self.tag.take().or(tag);
         self.docs = docs;
         self.concrete.extend(concrete);
+        self.bound = self.bound
+            .take()
+            .map(|b| b.into_iter().chain(bound.clone().unwrap_or_default()).collect())
+            .or(bound);
     }
 }
 
@@ -80,6 +88,7 @@ impl_parse! {
         "export" => out.export = true,
         "export_to" => out.export_to = Some(parse_assign_str(input)?),
         "concrete" => out.concrete = parse_concrete(input)?,
+        "bound" => out.bound = Some(parse_bound(input)?),
     }
 }
 
@@ -89,6 +98,7 @@ impl_parse! {
         "rename" => out.0.rename = Some(parse_assign_str(input)?),
         "rename_all" => out.0.rename_all = Some(parse_assign_str(input).and_then(Inflection::try_from)?),
         "tag" => out.0.tag = Some(parse_assign_str(input)?),
+        "bound" => out.0.bound = Some(parse_bound(input)?),
         // parse #[serde(default)] to not emit a warning
         "deny_unknown_fields" | "default" => {
             use syn::Token;
diff --git a/macros/src/lib.rs b/macros/src/lib.rs
index 0ddb662..c61e8b0 100644
--- a/macros/src/lib.rs
+++ b/macros/src/lib.rs
@@ -8,7 +8,7 @@ use quote::{format_ident, quote};
 use syn::{
     parse_quote, spanned::Spanned, ConstParam, GenericParam, Generics, Item, LifetimeParam, Result,
     Type, TypeArray, TypeParam, TypeParen, TypePath, TypeReference, TypeSlice, TypeTuple,
-    WhereClause,
+    WhereClause, WherePredicate,
 };
 
 use crate::{deps::Dependencies, utils::format_generics};
@@ -26,6 +26,7 @@ struct DerivedTS {
     inline_flattened: Option<TokenStream>,
     dependencies: Dependencies,
     concrete: HashMap<Ident, Type>,
+    bound: Option<Vec<WherePredicate>>,
 
     export: bool,
     export_to: Option<String>,
@@ -59,7 +60,12 @@ impl DerivedTS {
         };
 
         let ident = self.ts_name.clone();
-        let impl_start = generate_impl_block_header(&rust_ty, &generics, &self.dependencies);
+        let impl_start = generate_impl_block_header(
+            &rust_ty,
+            &generics,
+            self.bound.as_deref(),
+            &self.dependencies,
+        );
         let assoc_type = generate_assoc_type(&rust_ty, &generics, &self.concrete);
         let name = self.generate_name_fn(&generics);
         let inline = self.generate_inline_fn();
@@ -295,11 +301,12 @@ fn generate_assoc_type(
 fn generate_impl_block_header(
     ty: &Ident,
     generics: &Generics,
+    bounds: Option<&[WherePredicate]>,
     dependencies: &Dependencies,
 ) -> TokenStream {
     use GenericParam as G;
 
-    let bounds = generics.params.iter().map(|param| match param {
+    let params = generics.params.iter().map(|param| match param {
         G::Type(TypeParam {
             ident,
             colon_token,
@@ -325,8 +332,15 @@ fn generate_impl_block_header(
         G::Lifetime(LifetimeParam { lifetime, .. }) => quote!(#lifetime),
     });
 
-    let where_bound = generate_where_clause(generics, dependencies);
-    quote!(impl <#(#bounds),*> ::ts_rs::TS for #ty <#(#type_args),*> #where_bound)
+    let where_bound = match bounds {
+        Some(bounds) => quote! { where #(#bounds),* },
+        None => {
+            let bounds = generate_where_clause(generics, dependencies);
+            quote! { #bounds }
+        }
+    };
+
+    quote!(impl <#(#params),*> ::ts_rs::TS for #ty <#(#type_args),*> #where_bound)
 }
 
 fn generate_where_clause(generics: &Generics, dependencies: &Dependencies) -> WhereClause {
diff --git a/macros/src/types/enum.rs b/macros/src/types/enum.rs
index f94a6a7..f35cb98 100644
--- a/macros/src/types/enum.rs
+++ b/macros/src/types/enum.rs
@@ -30,6 +30,7 @@ pub(crate) fn r#enum_def(s: &ItemEnum) -> syn::Result<DerivedTS> {
             export: enum_attr.export,
             export_to: enum_attr.export_to,
             concrete: enum_attr.concrete,
+            bound: enum_attr.bound,
         });
     }
 
@@ -55,6 +56,7 @@ pub(crate) fn r#enum_def(s: &ItemEnum) -> syn::Result<DerivedTS> {
         export_to: enum_attr.export_to,
         ts_name: name,
         concrete: enum_attr.concrete,
+        bound: enum_attr.bound,
     })
 }
 
@@ -205,5 +207,6 @@ fn empty_enum(name: impl Into<String>, enum_attr: EnumAttr) -> DerivedTS {
         export_to: enum_attr.export_to,
         ts_name: name,
         concrete: enum_attr.concrete,
+        bound: enum_attr.bound,
     }
 }
diff --git a/macros/src/types/named.rs b/macros/src/types/named.rs
index c21c679..bd86ab6 100644
--- a/macros/src/types/named.rs
+++ b/macros/src/types/named.rs
@@ -1,5 +1,5 @@
 use proc_macro2::TokenStream;
-use quote::{quote, ToTokens};
+use quote::{quote};
 use syn::{spanned::Spanned, Field, FieldsNamed, GenericArgument, PathArguments, Result, Type};
 
 use crate::{
@@ -43,6 +43,8 @@ pub(crate) fn named(attr: &StructAttr, name: &str, fields: &FieldsNamed) -> Resu
     };
 
     Ok(DerivedTS {
+        // the `replace` combines `{ ... } & { ... }` into just one `{ ... }`. Not necessary, but it
+        // results in simpler type definitions.
         inline: quote!(#inline.replace(" } & { ", " ")),
         inline_flattened: Some(quote!(format!("{{ {} }}", #fields))),
         docs: attr.docs.clone(),
@@ -51,10 +53,11 @@ pub(crate) fn named(attr: &StructAttr, name: &str, fields: &FieldsNamed) -> Resu
         export_to: attr.export_to.clone(),
         ts_name: name.to_owned(),
         concrete: attr.concrete.clone(),
+        bound: attr.bound.clone(),
     })
 }
 
-// build an expresion which expands to a string, representing a single field of a struct.
+// build an expression which expands to a string, representing a single field of a struct.
 //
 // formatted_fields will contain all the fields that do not contain the flatten
 // attribute, in the format
@@ -90,11 +93,7 @@ fn format_field(
         syn_err_spanned!(field; "`type` is not compatible with `as`")
     }
 
-    let parsed_ty = if let Some(ref type_as) = type_as {
-        syn::parse_str::<Type>(&type_as.to_token_stream().to_string())?
-    } else {
-        field.ty.clone()
-    };
+    let parsed_ty = type_as.as_ref().unwrap_or(&field.ty).clone();
 
     let (ty, optional_annotation) = match optional {
         Optional {
diff --git a/macros/src/types/newtype.rs b/macros/src/types/newtype.rs
index 4d29fbd..759ebec 100644
--- a/macros/src/types/newtype.rs
+++ b/macros/src/types/newtype.rs
@@ -1,5 +1,5 @@
-use quote::{quote, ToTokens};
-use syn::{FieldsUnnamed, Result, Type};
+use quote::{quote};
+use syn::{FieldsUnnamed, Result};
 
 use crate::{
     attr::{FieldAttr, StructAttr},
@@ -42,11 +42,7 @@ pub(crate) fn newtype(attr: &StructAttr, name: &str, fields: &FieldsUnnamed) ->
         syn_err_spanned!(fields; "`type` is not compatible with `as`")
     }
 
-    let inner_ty = if let Some(ref type_as) = type_as {
-        syn::parse_str::<Type>(&type_as.to_token_stream().to_string())?
-    } else {
-        inner.ty.clone()
-    };
+    let inner_ty = type_as.as_ref().unwrap_or(&inner.ty).clone();
 
     let mut dependencies = Dependencies::default();
 
@@ -71,5 +67,6 @@ pub(crate) fn newtype(attr: &StructAttr, name: &str, fields: &FieldsUnnamed) ->
         export_to: attr.export_to.clone(),
         ts_name: name.to_owned(),
         concrete: attr.concrete.clone(),
+        bound: attr.bound.clone(),
     })
 }
diff --git a/macros/src/types/tuple.rs b/macros/src/types/tuple.rs
index b815b92..4aa0f40 100644
--- a/macros/src/types/tuple.rs
+++ b/macros/src/types/tuple.rs
@@ -1,6 +1,6 @@
 use proc_macro2::TokenStream;
-use quote::{quote, ToTokens};
-use syn::{Field, FieldsUnnamed, Result, Type};
+use quote::{quote};
+use syn::{Field, FieldsUnnamed, Result};
 
 use crate::{
     attr::{FieldAttr, StructAttr},
@@ -36,6 +36,7 @@ pub(crate) fn tuple(attr: &StructAttr, name: &str, fields: &FieldsUnnamed) -> Re
         export_to: attr.export_to.clone(),
         ts_name: name.to_owned(),
         concrete: attr.concrete.clone(),
+        bound: attr.bound.clone(),
     })
 }
 
@@ -59,11 +60,7 @@ fn format_field(
         return Ok(());
     }
 
-    let ty = if let Some(ref type_as) = type_as {
-        syn::parse_str::<Type>(&type_as.to_token_stream().to_string())?
-    } else {
-        field.ty.clone()
-    };
+    let ty = type_as.as_ref().unwrap_or(&field.ty).clone();
 
     if type_as.is_some() && type_override.is_some() {
         syn_err_spanned!(field; "`type` is not compatible with `as`")
diff --git a/macros/src/types/unit.rs b/macros/src/types/unit.rs
index 40bacb7..88cad88 100644
--- a/macros/src/types/unit.rs
+++ b/macros/src/types/unit.rs
@@ -15,6 +15,7 @@ pub(crate) fn empty_object(attr: &StructAttr, name: &str) -> Result<DerivedTS> {
         export_to: attr.export_to.clone(),
         ts_name: name.to_owned(),
         concrete: attr.concrete.clone(),
+        bound: attr.bound.clone(),
     })
 }
 
@@ -30,6 +31,7 @@ pub(crate) fn empty_array(attr: &StructAttr, name: &str) -> Result<DerivedTS> {
         export_to: attr.export_to.clone(),
         ts_name: name.to_owned(),
         concrete: attr.concrete.clone(),
+        bound: attr.bound.clone(),
     })
 }
 
@@ -45,6 +47,7 @@ pub(crate) fn null(attr: &StructAttr, name: &str) -> Result<DerivedTS> {
         export_to: attr.export_to.clone(),
         ts_name: name.to_owned(),
         concrete: attr.concrete.clone(),
+        bound: attr.bound.clone(),
     })
 }
 
diff --git a/ts-rs/Cargo.toml b/ts-rs/Cargo.toml
index 3ad9a33..30352ee 100644
--- a/ts-rs/Cargo.toml
+++ b/ts-rs/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "ts-rs"
-version = "7.1.1"
+version = "8.0.0"
 authors = ["Moritz Bischof <moritz.bischof1@gmail.com>"]
 edition = "2021"
 license = "MIT"
@@ -38,11 +38,11 @@ serde = { version = "1.0", features = ["derive"] }
 chrono = { version = "0.4", features = ["serde"] }
 
 [dependencies]
-heapless = { version = ">= 0.7, <= 0.8", optional = true }
-ts-rs-macros = { version = "7.1.1", path = "../macros" }
+heapless = { version = ">= 0.7, < 0.9", optional = true }
+ts-rs-macros = { version = "=8.0.0", path = "../macros" }
 dprint-plugin-typescript = { version = "0.89", optional = true }
 chrono = { version = "0.4", optional = true }
-bigdecimal = { version = ">= 0.0.13, <= 0.4", features = [
+bigdecimal = { version = ">= 0.0.13, < 0.5", features = [
   "serde",
 ], optional = true }
 uuid = { version = "1", optional = true }
@@ -52,4 +52,4 @@ url = { version = "2", optional = true }
 semver = { version = "1", optional = true }
 thiserror = "1"
 indexmap = { version = "2", optional = true }
-ordered-float = { version = ">= 3, <= 4", optional = true }
+ordered-float = { version = ">= 3, < 5", optional = true }
diff --git a/ts-rs/src/lib.rs b/ts-rs/src/lib.rs
index d21e251..69d6ed6 100644
--- a/ts-rs/src/lib.rs
+++ b/ts-rs/src/lib.rs
@@ -40,7 +40,7 @@
 //! ## get started
 //! ```toml
 //! [dependencies]
-//! ts-rs = "7.1"
+//! ts-rs = "8.0"
 //! ```
 //!
 //! ```rust
@@ -223,6 +223,44 @@ pub mod typelist;
 ///   ```
 ///   <br/><br/>
 ///
+/// - **`#[ts(bound)]`**
+///   Override the bounds generated on the `TS` implementation for this type. This is useful in
+///   combination with `#[ts(concrete)]`, when the type's generic parameters aren't directly used
+///   in a field or variant.
+///
+///   Example:
+///   ```
+///   # use ts_rs::TS;
+///
+///   trait Container {
+///       type Value: TS;
+///   }
+///
+///   struct MyContainer;
+///
+///   ##[derive(TS)]
+///   struct MyValue;
+///
+///   impl Container for MyContainer {
+///       type Value = MyValue;
+///   }
+///
+///   ##[derive(TS)]
+///   ##[ts(export, concrete(C = MyContainer))]
+///   struct Inner<C: Container> {
+///       value: C::Value,
+///   }
+///
+///   ##[derive(TS)]
+///   // Without `#[ts(bound)]`, `#[derive(TS)]` would generate an unnecessary
+///   // `C: TS` bound
+///   ##[ts(export, concrete(C = MyContainer), bound = "C::Value: TS")]
+///   struct Outer<C: Container> {
+///       inner: Inner<C>,
+///   }
+///   ```
+///   <br/><br/>
+///
 /// ### struct attributes
 /// - **`#[ts(tag = "..")]`**  
 ///   Include the structs name (or value of `#[ts(rename = "..")]`) as a field with the given key.


Test patch:
diff --git a/ts-rs/tests/bound.rs b/ts-rs/tests/bound.rs
new file mode 100644
index 0000000..485d2de
--- /dev/null
+++ b/ts-rs/tests/bound.rs
@@ -0,0 +1,36 @@
+#![allow(dead_code)]
+
+use ts_rs::TS;
+
+trait Driver {
+    type Info;
+}
+
+struct TsDriver;
+
+#[derive(TS)]
+struct TsInfo;
+
+impl Driver for TsDriver {
+    type Info = TsInfo;
+}
+
+#[derive(TS)]
+#[ts(export, export_to = "bound/")]
+#[ts(concrete(D = TsDriver))]
+struct Inner<D: Driver> {
+    info: D::Info,
+}
+
+#[derive(TS)]
+#[ts(export, export_to = "bound/")]
+#[ts(concrete(D = TsDriver), bound = "D::Info: TS")]
+struct Outer<D: Driver> {
+    inner: Inner<D>,
+}
+
+#[test]
+fn test_bound() {
+    assert_eq!(Outer::<TsDriver>::decl(), "type Outer = { inner: Inner, };");
+    assert_eq!(Inner::<TsDriver>::decl(), "type Inner = { info: TsInfo, };");
+}
diff --git a/ts-rs/tests/leading_colon.rs b/ts-rs/tests/leading_colon.rs
index 6ee31cd..cf0eabd 100644
--- a/ts-rs/tests/leading_colon.rs
+++ b/ts-rs/tests/leading_colon.rs
@@ -6,5 +6,5 @@ mod ts_rs {}
 
 #[derive(TS)]
 struct Foo {
-    x: u32
+    x: u32,
 }
diff --git a/ts-rs/tests/type_as.rs b/ts-rs/tests/type_as.rs
index 65e653c..4b55bb1 100644
--- a/ts-rs/tests/type_as.rs
+++ b/ts-rs/tests/type_as.rs
@@ -26,13 +26,20 @@ struct Override {
     // here, 'as' just behaves like 'type' (though it adds a dependency!)
     #[ts(as = "ExternalTypeDef")]
     y: Unsupported,
+    #[ts(as = "(i32, ExternalTypeDef, i32)")]
+    z: Unsupported,
 }
 
 #[test]
 fn struct_properties() {
     assert_eq!(
         Override::inline(),
-        "{ a: number, x: { a: number, b: number, c: number, }, y: ExternalTypeDef, }"
+        "{ \
+           a: number, \
+           x: { a: number, b: number, c: number, }, \
+           y: ExternalTypeDef, \
+           z: [number, ExternalTypeDef, number], \
+        }"
     );
     assert!(Override::dependencies()
         .iter()