Repository: Aleph-Alpha/ts-rs
Pull request #411 (2025-05-30 00:54:38)

Allow arbitrary expressions in `#[doc]`
## Goal

Allow for arbitrary expressions in `#[doc = ..]`. Currently, only string literals are supported.  
Motivation (cc @jtwaleson, #410):
```rust
#[derive(TS)]
#[ts(export)]
#[doc = concat!("defined in file ", file!())]
struct MyStruct;
```

## Changes
- Store `Vec<Expr>` in `Field/Struct/Enum/VariantAttr` instead of `String`
  - those are the raw expressions from `#[doc]`, not the already formatted JSDoc like before
  - while these are arbitrary `Expr`s, they are expected to evaluate to `&str`
- Defer creation of JSDoc to runtime
  - proc macro emits `ts_rs::format_docs(&[expr1, expr2, ..])`
  - `format_docs` is `#[doc(hidden)]`, and contains the same logic we used before (now during runtime, not during macro expansion)  
- Replace `TS::DOCS` with `TS::docs` (breaking)
## 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/CHANGELOG.md b/CHANGELOG.md
index 9c7b38e..c00a807 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,10 +2,14 @@
 ### Breaking
 - Changed return type of `TS::output_path()` from `Option<&'static Path>` to `Option<PathBuf>`.  
   This will only break your code if you manually implement `TS` or directly interact with the `TS` trait.
+- Replaced `TS::DOCS` with `TS::docs()`.  
+  This will only break your code if you manually implement `TS` or directly interact with the `TS` trait.
 - Added `OptionInnerType` associated type to the `TS` trait. If you manually implement `TS`, you must set this associated type to `Self` in all of your implementations.
 - Raised MSRV to `1.78.0` due to use of `#[diagnostic::on_unimplemented]` and `let ... else { ... }`
 
 ### Features
+- Add support for arbitrary expressions in doc attributes, e.g `#[doc = concat!("defined in ", file!())]`.
+  This would result both in a rustdoc and JSDoc comment.
 - The `#[ts(rename)]` attribute on structs, enums and variants now accepts any expression.  
   This makes it possible to, for example, rename a struct to the name of a module it is contained in using `#[ts(rename = module_path!().rsplit_once("::").unwrap().1)]`
 - The `#[ts(export_to)]` attribute on structs and enums now accepts any expression.
diff --git a/macros/src/attr/enum.rs b/macros/src/attr/enum.rs
index 11e83d3..074c6dc 100644
--- a/macros/src/attr/enum.rs
+++ b/macros/src/attr/enum.rs
@@ -5,7 +5,7 @@ use syn::{parse_quote, Attribute, Expr, Ident, ItemEnum, Path, Result, Type, Whe
 use super::{parse_assign_expr, parse_assign_from_str, parse_bound, Attr, ContainerAttr, Serde};
 use crate::{
     attr::{parse_assign_inflection, parse_assign_str, parse_concrete, Inflection},
-    utils::{parse_attrs, parse_docs},
+    utils::{extract_docs, parse_attrs},
 };
 
 #[derive(Default)]
@@ -18,7 +18,7 @@ pub struct EnumAttr {
     pub rename: Option<Expr>,
     pub export_to: Option<Expr>,
     pub export: bool,
-    pub docs: String,
+    pub docs: Vec<Expr>,
     pub concrete: HashMap<Ident, Type>,
     pub bound: Option<Vec<WherePredicate>>,
     pub tag: Option<String>,
@@ -55,8 +55,7 @@ impl EnumAttr {
             result = result.merge(serde_attr.0);
         }
 
-        let docs = parse_docs(attrs)?;
-        result.docs = docs;
+        result.docs = extract_docs(attrs);
 
         Ok(result)
     }
diff --git a/macros/src/attr/field.rs b/macros/src/attr/field.rs
index 441ae4a..bff398d 100644
--- a/macros/src/attr/field.rs
+++ b/macros/src/attr/field.rs
@@ -1,254 +1,254 @@
-use syn::{
-    AngleBracketedGenericArguments, Attribute, Field, GenericArgument, Ident, PathArguments, QSelf,
-    Result, ReturnType, Type, TypeArray, TypeGroup, TypeParen, TypePath, TypePtr, TypeReference,
-    TypeSlice, TypeTuple,
-};
-
-use super::{parse_assign_from_str, parse_assign_str, parse_optional, Attr, Optional, Serde};
-use crate::utils::{parse_attrs, parse_docs};
-
-#[derive(Default)]
-pub struct FieldAttr {
-    type_as: Option<Type>,
-    pub type_override: Option<String>,
-    pub rename: Option<String>,
-    pub inline: bool,
-    pub skip: bool,
-    pub optional: Optional,
-    pub flatten: bool,
-    pub docs: String,
-
-    pub using_serde_with: bool,
-}
-
-impl FieldAttr {
-    pub fn from_attrs(attrs: &[Attribute]) -> Result<Self> {
-        let mut result = parse_attrs::<Self>(attrs)?;
-
-        if cfg!(feature = "serde-compat") && !result.skip {
-            let serde_attr = crate::utils::parse_serde_attrs::<FieldAttr>(attrs);
-            result = result.merge(serde_attr.0);
-        }
-
-        result.docs = parse_docs(attrs)?;
-
-        Ok(result)
-    }
-
-    pub fn type_as(&self, original_type: &Type) -> Type {
-        if let Some(mut ty) = self.type_as.clone() {
-            replace_underscore(&mut ty, original_type);
-            ty
-        } else {
-            original_type.clone()
-        }
-    }
-}
-
-impl Attr for FieldAttr {
-    type Item = Field;
-
-    fn merge(self, other: Self) -> Self {
-        Self {
-            type_as: self.type_as.or(other.type_as),
-            type_override: self.type_override.or(other.type_override),
-            rename: self.rename.or(other.rename),
-            inline: self.inline || other.inline,
-            skip: self.skip || other.skip,
-            optional: self.optional.or(other.optional),
-            flatten: self.flatten || other.flatten,
-
-            using_serde_with: self.using_serde_with || other.using_serde_with,
-
-            // We can't emit TSDoc for a flattened field
-            // and we cant make this invalid in assert_validity because
-            // this documentation is totally valid in Rust
-            docs: if self.flatten || other.flatten {
-                String::new()
-            } else {
-                self.docs + &other.docs
-            },
-        }
-    }
-
-    fn assert_validity(&self, field: &Self::Item) -> Result<()> {
-        if cfg!(feature = "serde-compat")
-            && self.using_serde_with
-            && !(self.type_as.is_some() || self.type_override.is_some())
-        {
-            syn_err_spanned!(
-                field;
-                r#"using `#[serde(with = "...")]` requires the use of `#[ts(as = "...")]` or `#[ts(type = "...")]`"#
-            )
-        }
-
-        if self.type_override.is_some() {
-            if self.type_as.is_some() {
-                syn_err_spanned!(field; "`type` is not compatible with `as`")
-            }
-
-            if self.inline {
-                syn_err_spanned!(field; "`type` is not compatible with `inline`")
-            }
-
-            if self.flatten {
-                syn_err_spanned!(
-                    field;
-                    "`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 {
-            if self.type_as.is_some() {
-                syn_err_spanned!(
-                    field;
-                    "`as` is not compatible with `flatten`"
-                );
-            }
-
-            if self.rename.is_some() {
-                syn_err_spanned!(
-                    field;
-                    "`rename` is not compatible with `flatten`"
-                );
-            }
-
-            if self.inline {
-                syn_err_spanned!(
-                    field;
-                    "`inline` is not compatible with `flatten`"
-                );
-            }
-
-            if let Optional::Optional { .. } = self.optional {
-                syn_err_spanned!(
-                    field;
-                    "`optional` is not compatible with `flatten`"
-                );
-            }
-        }
-
-        if field.ident.is_none() {
-            if self.flatten {
-                syn_err_spanned!(
-                    field;
-                    "`flatten` cannot with tuple struct fields"
-                );
-            }
-
-            if self.rename.is_some() {
-                syn_err_spanned!(
-                    field;
-                    "`flatten` cannot with tuple struct fields"
-                );
-            }
-
-            if let Optional::Optional { .. } = self.optional {
-                syn_err_spanned!(
-                    field;
-                    "`optional` cannot with tuple struct fields"
-                );
-            }
-        }
-
-        Ok(())
-    }
-}
-
-impl_parse! {
-    FieldAttr(input, out) {
-        "as" => out.type_as = Some(parse_assign_from_str(input)?),
-        "type" => out.type_override = Some(parse_assign_str(input)?),
-        "rename" => out.rename = Some(parse_assign_str(input)?),
-        "inline" => out.inline = true,
-        "skip" => out.skip = true,
-        "optional" => out.optional = parse_optional(input)?,
-        "flatten" => out.flatten = true,
-    }
-}
-
-impl_parse! {
-    Serde<FieldAttr>(input, out) {
-        "rename" => out.0.rename = Some(parse_assign_str(input)?),
-        "skip" => out.0.skip = true,
-        "flatten" => out.0.flatten = true,
-        // parse #[serde(default)] to not emit a warning
-        "default" => {
-            use syn::Token;
-            if input.peek(Token![=]) {
-                parse_assign_str(input)?;
-            }
-        },
-        "with" => {
-            parse_assign_str(input)?;
-            out.0.using_serde_with = true;
-        },
-    }
-}
-
-fn replace_underscore(ty: &mut Type, with: &Type) {
-    match ty {
-        Type::Infer(_) => *ty = with.clone(),
-        Type::Array(TypeArray { elem, .. })
-        | Type::Group(TypeGroup { elem, .. })
-        | Type::Paren(TypeParen { elem, .. })
-        | Type::Ptr(TypePtr { elem, .. })
-        | Type::Reference(TypeReference { elem, .. })
-        | Type::Slice(TypeSlice { elem, .. }) => {
-            replace_underscore(elem, with);
-        }
-        Type::Tuple(TypeTuple { elems, .. }) => {
-            for elem in elems {
-                replace_underscore(elem, with);
-            }
-        }
-        Type::Path(TypePath { path, qself }) => {
-            if let Some(QSelf { ty, .. }) = qself {
-                replace_underscore(ty, with);
-            }
-
-            for segment in &mut path.segments {
-                match &mut segment.arguments {
-                    PathArguments::None => (),
-                    PathArguments::AngleBracketed(a) => {
-                        replace_underscore_in_angle_bracketed(a, with);
-                    }
-                    PathArguments::Parenthesized(p) => {
-                        for input in &mut p.inputs {
-                            replace_underscore(input, with);
-                        }
-                        if let ReturnType::Type(_, output) = &mut p.output {
-                            replace_underscore(output, with);
-                        }
-                    }
-                }
-            }
-        }
-        _ => (),
-    }
-}
-
-fn replace_underscore_in_angle_bracketed(args: &mut AngleBracketedGenericArguments, with: &Type) {
-    for arg in &mut args.args {
-        match arg {
-            GenericArgument::Type(ty) => {
-                replace_underscore(ty, with);
-            }
-            GenericArgument::AssocType(assoc_ty) => {
-                replace_underscore(&mut assoc_ty.ty, with);
-                if let Some(g) = &mut assoc_ty.generics {
-                    replace_underscore_in_angle_bracketed(g, with);
-                }
-            }
-            _ => (),
-        }
-    }
-}
+use syn::{
+    AngleBracketedGenericArguments, Attribute, Expr, Field, GenericArgument, Ident, PathArguments,
+    QSelf, Result, ReturnType, Type, TypeArray, TypeGroup, TypeParen, TypePath, TypePtr,
+    TypeReference, TypeSlice, TypeTuple,
+};
+
+use super::{parse_assign_from_str, parse_assign_str, parse_optional, Attr, Optional, Serde};
+use crate::utils::{extract_docs, parse_attrs};
+
+#[derive(Default)]
+pub struct FieldAttr {
+    type_as: Option<Type>,
+    pub type_override: Option<String>,
+    pub rename: Option<String>,
+    pub inline: bool,
+    pub skip: bool,
+    pub optional: Optional,
+    pub flatten: bool,
+    pub docs: Vec<Expr>,
+
+    pub using_serde_with: bool,
+}
+
+impl FieldAttr {
+    pub fn from_attrs(attrs: &[Attribute]) -> Result<Self> {
+        let mut result = parse_attrs::<Self>(attrs)?;
+
+        if cfg!(feature = "serde-compat") && !result.skip {
+            let serde_attr = crate::utils::parse_serde_attrs::<FieldAttr>(attrs);
+            result = result.merge(serde_attr.0);
+        }
+
+        result.docs = extract_docs(attrs);
+
+        Ok(result)
+    }
+
+    pub fn type_as(&self, original_type: &Type) -> Type {
+        if let Some(mut ty) = self.type_as.clone() {
+            replace_underscore(&mut ty, original_type);
+            ty
+        } else {
+            original_type.clone()
+        }
+    }
+}
+
+impl Attr for FieldAttr {
+    type Item = Field;
+
+    fn merge(self, other: Self) -> Self {
+        Self {
+            type_as: self.type_as.or(other.type_as),
+            type_override: self.type_override.or(other.type_override),
+            rename: self.rename.or(other.rename),
+            inline: self.inline || other.inline,
+            skip: self.skip || other.skip,
+            optional: self.optional.or(other.optional),
+            flatten: self.flatten || other.flatten,
+
+            using_serde_with: self.using_serde_with || other.using_serde_with,
+
+            // We can't emit TSDoc for a flattened field
+            // and we cant make this invalid in assert_validity because
+            // this documentation is totally valid in Rust
+            docs: if self.flatten || other.flatten {
+                vec![]
+            } else {
+                [self.docs, other.docs].concat()
+            },
+        }
+    }
+
+    fn assert_validity(&self, field: &Self::Item) -> Result<()> {
+        if cfg!(feature = "serde-compat")
+            && self.using_serde_with
+            && !(self.type_as.is_some() || self.type_override.is_some())
+        {
+            syn_err_spanned!(
+                field;
+                r#"using `#[serde(with = "...")]` requires the use of `#[ts(as = "...")]` or `#[ts(type = "...")]`"#
+            )
+        }
+
+        if self.type_override.is_some() {
+            if self.type_as.is_some() {
+                syn_err_spanned!(field; "`type` is not compatible with `as`")
+            }
+
+            if self.inline {
+                syn_err_spanned!(field; "`type` is not compatible with `inline`")
+            }
+
+            if self.flatten {
+                syn_err_spanned!(
+                    field;
+                    "`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 {
+            if self.type_as.is_some() {
+                syn_err_spanned!(
+                    field;
+                    "`as` is not compatible with `flatten`"
+                );
+            }
+
+            if self.rename.is_some() {
+                syn_err_spanned!(
+                    field;
+                    "`rename` is not compatible with `flatten`"
+                );
+            }
+
+            if self.inline {
+                syn_err_spanned!(
+                    field;
+                    "`inline` is not compatible with `flatten`"
+                );
+            }
+
+            if let Optional::Optional { .. } = self.optional {
+                syn_err_spanned!(
+                    field;
+                    "`optional` is not compatible with `flatten`"
+                );
+            }
+        }
+
+        if field.ident.is_none() {
+            if self.flatten {
+                syn_err_spanned!(
+                    field;
+                    "`flatten` cannot with tuple struct fields"
+                );
+            }
+
+            if self.rename.is_some() {
+                syn_err_spanned!(
+                    field;
+                    "`flatten` cannot with tuple struct fields"
+                );
+            }
+
+            if let Optional::Optional { .. } = self.optional {
+                syn_err_spanned!(
+                    field;
+                    "`optional` cannot with tuple struct fields"
+                );
+            }
+        }
+
+        Ok(())
+    }
+}
+
+impl_parse! {
+    FieldAttr(input, out) {
+        "as" => out.type_as = Some(parse_assign_from_str(input)?),
+        "type" => out.type_override = Some(parse_assign_str(input)?),
+        "rename" => out.rename = Some(parse_assign_str(input)?),
+        "inline" => out.inline = true,
+        "skip" => out.skip = true,
+        "optional" => out.optional = parse_optional(input)?,
+        "flatten" => out.flatten = true,
+    }
+}
+
+impl_parse! {
+    Serde<FieldAttr>(input, out) {
+        "rename" => out.0.rename = Some(parse_assign_str(input)?),
+        "skip" => out.0.skip = true,
+        "flatten" => out.0.flatten = true,
+        // parse #[serde(default)] to not emit a warning
+        "default" => {
+            use syn::Token;
+            if input.peek(Token![=]) {
+                parse_assign_str(input)?;
+            }
+        },
+        "with" => {
+            parse_assign_str(input)?;
+            out.0.using_serde_with = true;
+        },
+    }
+}
+
+fn replace_underscore(ty: &mut Type, with: &Type) {
+    match ty {
+        Type::Infer(_) => *ty = with.clone(),
+        Type::Array(TypeArray { elem, .. })
+        | Type::Group(TypeGroup { elem, .. })
+        | Type::Paren(TypeParen { elem, .. })
+        | Type::Ptr(TypePtr { elem, .. })
+        | Type::Reference(TypeReference { elem, .. })
+        | Type::Slice(TypeSlice { elem, .. }) => {
+            replace_underscore(elem, with);
+        }
+        Type::Tuple(TypeTuple { elems, .. }) => {
+            for elem in elems {
+                replace_underscore(elem, with);
+            }
+        }
+        Type::Path(TypePath { path, qself }) => {
+            if let Some(QSelf { ty, .. }) = qself {
+                replace_underscore(ty, with);
+            }
+
+            for segment in &mut path.segments {
+                match &mut segment.arguments {
+                    PathArguments::None => (),
+                    PathArguments::AngleBracketed(a) => {
+                        replace_underscore_in_angle_bracketed(a, with);
+                    }
+                    PathArguments::Parenthesized(p) => {
+                        for input in &mut p.inputs {
+                            replace_underscore(input, with);
+                        }
+                        if let ReturnType::Type(_, output) = &mut p.output {
+                            replace_underscore(output, with);
+                        }
+                    }
+                }
+            }
+        }
+        _ => (),
+    }
+}
+
+fn replace_underscore_in_angle_bracketed(args: &mut AngleBracketedGenericArguments, with: &Type) {
+    for arg in &mut args.args {
+        match arg {
+            GenericArgument::Type(ty) => {
+                replace_underscore(ty, with);
+            }
+            GenericArgument::AssocType(assoc_ty) => {
+                replace_underscore(&mut assoc_ty.ty, with);
+                if let Some(g) = &mut assoc_ty.generics {
+                    replace_underscore_in_angle_bracketed(g, with);
+                }
+            }
+            _ => (),
+        }
+    }
+}
diff --git a/macros/src/attr/struct.rs b/macros/src/attr/struct.rs
index ffcb52b..aa532e3 100644
--- a/macros/src/attr/struct.rs
+++ b/macros/src/attr/struct.rs
@@ -8,7 +8,7 @@ use super::{
 };
 use crate::{
     attr::{parse_assign_str, EnumAttr, Inflection, VariantAttr},
-    utils::{parse_attrs, parse_docs},
+    utils::{extract_docs, parse_attrs},
 };
 
 #[derive(Default, Clone)]
@@ -21,7 +21,7 @@ pub struct StructAttr {
     pub export_to: Option<Expr>,
     pub export: bool,
     pub tag: Option<String>,
-    pub docs: String,
+    pub docs: Vec<Expr>,
     pub concrete: HashMap<Ident, Type>,
     pub bound: Option<Vec<WherePredicate>>,
     pub optional_fields: Optional,
@@ -36,8 +36,7 @@ impl StructAttr {
             result = result.merge(serde_attr.0);
         }
 
-        let docs = parse_docs(attrs)?;
-        result.docs = docs;
+        result.docs = extract_docs(attrs);
 
         Ok(result)
     }
diff --git a/macros/src/lib.rs b/macros/src/lib.rs
index 8f4eecc..e503ab2 100644
--- a/macros/src/lib.rs
+++ b/macros/src/lib.rs
@@ -22,7 +22,7 @@ mod types;
 struct DerivedTS {
     crate_rename: Path,
     ts_name: Expr,
-    docs: String,
+    docs: Vec<Expr>,
     inline: TokenStream,
     inline_flattened: Option<TokenStream>,
     dependencies: Dependencies,
@@ -63,13 +63,16 @@ impl DerivedTS {
             }
         };
 
+        let crate_rename = self.crate_rename.clone();
         let docs = match &*self.docs {
-            "" => None,
-            docs => Some(quote!(const DOCS: Option<&'static str> = Some(#docs);)),
+            [] => None,
+            docs => Some(quote! {
+                fn docs() -> Option<String> {
+                    Some(#crate_rename::format_docs(&[#(#docs),*]))
+                }
+            }),
         };
 
-        let crate_rename = self.crate_rename.clone();
-
         let ident = self.ts_name.clone();
         let impl_start = generate_impl_block_header(
             &crate_rename,
diff --git a/macros/src/types/named.rs b/macros/src/types/named.rs
index c15e9c4..f7943de 100644
--- a/macros/src/types/named.rs
+++ b/macros/src/types/named.rs
@@ -111,9 +111,9 @@ fn format_field(
         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.is_empty() {
-            true => "".to_string(),
-            false => format!("\n{}", &field_attr.docs),
+        let docs = match &*field_attr.docs {
+            &[] => quote!(""),
+            docs => quote!(format!("\n{}", #crate_rename::format_docs(&[#(#docs),*]))),
         };
 
         formatted_fields.push(quote! {
@@ -177,9 +177,9 @@ fn format_field(
     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.is_empty() {
-        true => "".to_string(),
-        false => format!("\n{}", &field_attr.docs),
+    let docs = match &*field_attr.docs {
+        &[] => quote!(""),
+        docs => quote!(format!("\n{}", #crate_rename::format_docs(&[#(#docs),*]))),
     };
 
     formatted_fields.push(quote! {
diff --git a/macros/src/utils.rs b/macros/src/utils.rs
index 7bd04b8..6710c10 100644
--- a/macros/src/utils.rs
+++ b/macros/src/utils.rs
@@ -3,8 +3,7 @@ use std::collections::HashMap;
 use proc_macro2::{Ident, Span, TokenStream};
 use quote::quote;
 use syn::{
-    spanned::Spanned, Attribute, Error, Expr, ExprLit, GenericParam, Generics, Lit, LitStr, Path,
-    Result, Type,
+    Attribute, Error, Expr, ExprLit, GenericParam, Generics, Lit, LitStr, Path, Result, Type,
 };
 
 use super::attr::{Attr, Serde};
@@ -165,45 +164,15 @@ where
         .fold(Serde::<A>::default(), |acc, cur| acc.merge(cur))
 }
 
-/// Return doc comments parsed and formatted as JSDoc.
-pub fn parse_docs(attrs: &[Attribute]) -> Result<String> {
-    let doc_attrs = attrs
+/// Extracts doc comments from the given attributes, returning a `syn::Expr` for every `#[doc]`
+/// attribute. These `syn::Expr`s are expected to evaluate to `&str`.
+pub fn extract_docs(attrs: &[Attribute]) -> Vec<Expr> {
+    attrs
         .iter()
         .filter_map(|attr| attr.meta.require_name_value().ok())
         .filter(|attr| attr.path.is_ident("doc"))
-        .map(|attr| match attr.value {
-            Expr::Lit(ExprLit {
-                lit: Lit::Str(ref str),
-                ..
-            }) => Ok(str.value()),
-            _ => syn_err!(attr.span(); "doc  with non literal expression found"),
-        })
-        .collect::<Result<Vec<_>>>()?;
-
-    Ok(match doc_attrs.len() {
-        // No docs
-        0 => String::new(),
-
-        // Multi-line block doc comment (/** ... */)
-        1 if doc_attrs[0].contains('\n') => format!("/**{}*/\n", &doc_attrs[0]),
-
-        // Regular doc comment(s) (///) or single line block doc comment
-        _ => {
-            let mut buffer = String::from("/**\n");
-            let mut lines = doc_attrs.iter().peekable();
-
-            while let Some(line) = lines.next() {
-                buffer.push_str(" *");
-                buffer.push_str(line);
-
-                if lines.peek().is_some() {
-                    buffer.push('\n');
-                }
-            }
-            buffer.push_str("\n */\n");
-            buffer
-        }
-    })
+        .map(|attr| attr.value.clone())
+        .collect()
 }
 
 #[cfg(feature = "serde-compat")]
diff --git a/ts-rs/src/export.rs b/ts-rs/src/export.rs
index 9f3936c..b0e9413 100644
--- a/ts-rs/src/export.rs
+++ b/ts-rs/src/export.rs
@@ -312,9 +312,8 @@ pub(crate) fn default_out_dir() -> Cow<'static, Path> {
 /// Push the declaration of `T`
 fn generate_decl<T: TS + ?Sized>(out: &mut String) {
     // Type Docs
-    let docs = &<T as crate::TS>::DOCS;
-    if let Some(docs) = docs {
-        out.push_str(docs);
+    if let Some(docs) = <T as crate::TS>::docs() {
+        out.push_str(&docs);
     }
 
     // Type Definition
diff --git a/ts-rs/src/lib.rs b/ts-rs/src/lib.rs
index 9cc9303..f0e4af2 100644
--- a/ts-rs/src/lib.rs
+++ b/ts-rs/src/lib.rs
@@ -404,13 +404,15 @@ pub trait TS {
     /// 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;
 
+    /// JSDoc comment to describe this type in TypeScript - when `TS` is derived, docs are
+    /// automatically read from your doc comments or `#[doc = ".."]` attributes
+    fn docs() -> Option<String> {
+        None
+    }
+
     /// Identifier of this type, excluding generic parameters.
     fn ident() -> String {
         // by default, fall back to `TS::name()`.
@@ -1166,3 +1168,34 @@ impl TS for Dummy {
         panic!("{} cannot be flattened", <Self as crate::TS>::name())
     }
 }
+
+/// Formats rust doc comments, turning them into a JSDoc comments.
+/// Expects a `&[&str]` where each element corresponds to the value of one `#[doc]` attribute.
+/// This work is deferred to runtime, allowing expressions in `#[doc]`, e.g `#[doc = file!()]`.
+#[doc(hidden)]
+pub fn format_docs(docs: &[&str]) -> String {
+    match docs {
+        // No docs
+        [] => String::new(),
+
+        // Multi-line block doc comment (/** ... */)
+        [doc] if doc.contains('\n') => format!("/**{doc}*/\n"),
+
+        // Regular doc comment(s) (///) or single line block doc comment
+        _ => {
+            let mut buffer = String::from("/**\n");
+            let mut lines = docs.iter().peekable();
+
+            while let Some(line) = lines.next() {
+                buffer.push_str(" *");
+                buffer.push_str(line);
+
+                if lines.peek().is_some() {
+                    buffer.push('\n');
+                }
+            }
+            buffer.push_str("\n */\n");
+            buffer
+        }
+    }
+}


Test patch:
diff --git a/ts-rs/tests/integration/docs.rs b/ts-rs/tests/integration/docs.rs
index 9c5ebc0..daf710a 100644
--- a/ts-rs/tests/integration/docs.rs
+++ b/ts-rs/tests/integration/docs.rs
@@ -102,6 +102,17 @@ struct H {
     foo: i32,
 }
 
+#[derive(TS)]
+#[ts(export_to = "docs/")]
+#[doc = concat!("line ", line!())]
+struct I {
+    #[doc = concat!("column ", column!())]
+    a: i32,
+
+    #[doc = concat!("path ", module_path!())]
+    b: i32,
+}
+
 #[test]
 fn export_a() {
     A::export().unwrap();
@@ -425,3 +436,47 @@ fn export_h() {
 
     assert_eq!(actual_content, expected_content);
 }
+
+#[test]
+fn export_i() {
+    I::export().unwrap();
+
+    let expected_content = if cfg!(feature = "format") {
+        concat!(
+        "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.\n\n",
+        "/**\n",
+        " * line 107\n",
+        " */\n",
+        "export type I = {\n",
+        "  /**\n",
+        "   * column 32\n",
+        "   */\n",
+        "  a: number;\n",
+        "  /**\n",
+        "   * path integration::docs\n",
+        "   */\n",
+        "  b: number;\n",
+        "};\n",
+        )
+    } else {
+        concat!(
+        "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.\n\n",
+        "/**\n",
+        " *line 107\n",
+        " */\n",
+        "export type I = { \n",
+        "/**\n",
+        " *column 32\n",
+        " */\n",
+        "a: number, \n",
+        "/**\n",
+        " *path integration::docs\n",
+        " */\n",
+        "b: number, };\n",
+        )
+    };
+
+    let actual_content = fs::read_to_string(I::default_output_path().unwrap()).unwrap();
+
+    assert_eq!(actual_content, expected_content);
+}