Repository: Aleph-Alpha/ts-rs (rust)

Issue:
Feature request: Easier way to output typescript optionals, IE `?: Thing` for rust `Option` types, rather than `Thing | null`
**Is your feature request related to a problem? Please describe.**

We're upgradiing our `ts-rs` from `7.1.1` -> `10.0.0`, but unfortunately this upgrade broke all of our `Option` types.

Beforehand, we could do this on a struct:

```rust
#[skip_serializing_none]
#[cfg_attr(feature = "full", ts(export))]
pub struct AdminPurgePersonView {
  admin: Option<Person>
}
```

And this would generate:

```typescript
export interface AdminPurgePersonView {
  admin?: Person
```

With the upgrade, the above now generates: 

`admin: Person | null`
  
This must be because ts-rs is now ignoring serde's `skip_serializing_none` directive.

**Describe the solution you'd like**

Have ts-rs follow the above directive.

**Describe alternatives you've considered**

- Add `ts(optional)` to every single field, but this would be tedious and verbose.
- Change the default behavior of rust's options to match actual generated [typescript optionals](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#optional-properties) (IE `?`) , rather than outputting `thing | null`, which isn't the preferred way typescript deals with optionals.

You don't need to get to into it if you don't want, but what's the short version for why ts-rs outputs `thing | null` rather than proper typescript [optionals](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#optional-properties)?


Pull request:
Add `#[ts(optional)]` to struct
## Goal

Allow the use of `#[ts(optional)]` on struct definitions
Closes #364

## 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 7ac0164..cdb4c97 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,14 @@
+# master
+### Breaking
+- 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
+- Added `#[ts(optional_fields)]` and `#[ts(optional_fields = nullable)]` attribute to structs, this attribute is equivalent to using the corresponding `#[ts(optional)]` or `#[ts(optional = nullable)]` on every field of the struct. ([#366](https://github.com/Aleph-Alpha/ts-rs/pull/366))
+
+### Fixes
+- Fix `#[ts(optional)]` error when using a type alias for `Option` or fully qqualifying it as `core::option::Option` ([#366](https://github.com/Aleph-Alpha/ts-rs/pull/366))
+
 # 10.1.0
 ### Features
 - Add support for synchronization primitives from `tokio` (feature `tokio-impl`)
diff --git a/README.md b/README.md
index 1b931f0..336d940 100644
--- a/README.md
+++ b/README.md
@@ -124,6 +124,6 @@ Feel free to open an issue, discuss using GitHub discussions or open a PR.
 [See CONTRIBUTING.md](https://github.com/Aleph-Alpha/ts-rs/blob/main/CONTRIBUTING.md)
 
 ### MSRV
-The Minimum Supported Rust Version for this crate is 1.63.0
+The Minimum Supported Rust Version for this crate is 1.78.0
 
 License: MIT
diff --git a/macros/src/attr/field.rs b/macros/src/attr/field.rs
index c7726ef..441ae4a 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<T>` 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<Self> {
         let mut result = parse_attrs::<Self>(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,
@@ -109,6 +97,13 @@ 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 {
@@ -133,7 +128,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 +151,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 +170,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::<Token![=]>()?;
-                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 27d12eb..61debed 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<T>` 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<Vec<WherePredicate>> {
         other => Err(Error::new(other.span(), "expected string")),
     }
 }
+
+fn parse_optional(input: ParseStream) -> Result<Optional> {
+    let nullable = if input.peek(Token![=]) {
+        input.parse::<Token![=]>()?;
+        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 fc4d4cb..80d1e12 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<Ident, Type>,
     pub bound: Option<Vec<WherePredicate>>,
+    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 4be1ce5..a1ade3a 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 95cd38d..1f43c3d 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<DerivedTS> {
     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 95a658e..6b9a2de 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<DerivedTS> {
     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<Inflection>,
+    struct_optional: Optional,
 ) -> Result<()> {
     let field_attr = FieldAttr::from_attrs(&field.attrs)?;
 
@@ -102,42 +102,72 @@ fn format_field(
         return Ok(());
     }
 
-    let parsed_ty = field_attr.type_as(&field.ty);
+    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.is_empty() {
+            true => "".to_string(),
+            false => format!("\n{}", &field_attr.docs),
+        };
 
-    let (ty, optional_annotation) = match field_attr.optional {
-        Optional {
-            optional: true,
+        formatted_fields.push(quote! {
+            format!("{}{}: {},", #docs, #valid_name, #type_override)
+        });
+
+        return Ok(());
+    }
+
+    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<T: #crate_rename::IsOption>(_: std::marker::PhantomData<T>) {}
+                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(());
     }
 
-    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 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 field_name = to_ts_ident(field.ident.as_ref().unwrap());
     let name = match (field_attr.rename, rename_all) {
@@ -159,28 +189,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<T> type"),
-    }
-}
diff --git a/ts-rs/Cargo.toml b/ts-rs/Cargo.toml
index 85d00d4..805430d 100644
--- a/ts-rs/Cargo.toml
+++ b/ts-rs/Cargo.toml
@@ -15,7 +15,7 @@ categories = [
     "web-programming",
 ]
 readme = "../README.md"
-rust-version = "1.63.0"
+rust-version = "1.78.0"
 
 [features]
 chrono-impl = ["chrono"]
diff --git a/ts-rs/src/chrono.rs b/ts-rs/src/chrono.rs
index 281ec43..ab57d02 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<T: TimeZone + 'static> TS for DateTime<T> {
     type WithoutGenerics = Self;
+    type OptionInnerType = Self;
+
     fn ident() -> String {
         "string".to_owned()
     }
@@ -48,6 +52,8 @@ impl<T: TimeZone + 'static> TS for DateTime<T> {
 
 impl<T: TimeZone + 'static> TS for Date<T> {
     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 3640c96..a811bd9 100644
--- a/ts-rs/src/export.rs
+++ b/ts-rs/src/export.rs
@@ -47,7 +47,7 @@ mod recursive_export {
         error: Option<ExportError>,
     }
 
-    impl<'a> TypeVisitor for Visit<'a> {
+    impl TypeVisitor for Visit<'_> {
         fn visit<T: TS + 'static + ?Sized>(&mut self) {
             // if an error occurred previously, or the type cannot be exported (it's a primitive),
             // we return
diff --git a/ts-rs/src/lib.rs b/ts-rs/src/lib.rs
index b2bbc07..085f62b 100644
--- a/ts-rs/src/lib.rs
+++ b/ts-rs/src/lib.rs
@@ -122,7 +122,7 @@
 //! [See CONTRIBUTING.md](https://github.com/Aleph-Alpha/ts-rs/blob/main/CONTRIBUTING.md)
 //!
 //! ## MSRV
-//! The Minimum Supported Rust Version for this crate is 1.63.0
+//! The Minimum Supported Rust Version for this crate is 1.78.0
 
 use std::{
     any::TypeId,
@@ -286,6 +286,12 @@ mod tokio;
 ///   Include the structs name (or value of `#[ts(rename = "..")]`) as a field with the given key.
 ///   <br/><br/>
 ///
+/// - **`#[ts(optional_fields)]`**  
+///   Makes all `Option<T>` fields in a struct optional.
+///   If `#[ts(optional_fields)]` is present, `t?: T` is generated for every `Option<T>` field of the struct.  
+///   If `#[ts(optional_fields = nullable)]` is present, `t?: T | null` is generated for every `Option<T>` field of the struct.  
+///   <br/><br/>
+///
 /// ### struct field attributes
 ///
 /// - **`#[ts(type = "..")]`**  
@@ -380,6 +386,7 @@ pub trait TS {
     /// struct GenericType<A, B>(A, B);
     /// impl<A, B> TS for GenericType<A, B> {
     ///     type WithoutGenerics = GenericType<ts_rs::Dummy, ts_rs::Dummy>;
+    ///     type OptionInnerType = Self;
     ///     // ...
     ///     # fn decl() -> String { todo!() }
     ///     # fn decl_concrete() -> String { todo!() }
@@ -390,10 +397,17 @@ pub trait TS {
     /// ```
     type WithoutGenerics: TS + ?Sized;
 
+    /// If the implementing type is `std::option::Option<T>`, 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()`.
@@ -451,7 +465,7 @@ pub trait TS {
     {
         let mut deps: Vec<Dependency> = vec![];
         struct Visit<'a>(&'a mut Vec<Dependency>);
-        impl<'a> TypeVisitor for Visit<'a> {
+        impl TypeVisitor for Visit<'_> {
             fn visit<T: TS + 'static + ?Sized>(&mut self) {
                 if let Some(dep) = Dependency::from_ty::<T>() {
                     self.0.push(dep);
@@ -619,11 +633,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<T> IsOption for Option<T> {}
+
 // 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 { <Self as $crate::TS>::name() }
             fn inline_flattened() -> String { panic!("{} cannot be flattened", <Self as $crate::TS>::name()) }
@@ -637,6 +662,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 +695,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 +724,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 +750,8 @@ macro_rules! impl_shadow {
 
 impl<T: TS> TS for Option<T> {
     type WithoutGenerics = Self;
+    type OptionInnerType = T;
+    const IS_OPTION: bool = true;
 
     fn name() -> String {
         format!("{} | null", T::name())
@@ -761,6 +791,7 @@ impl<T: TS> TS for Option<T> {
 
 impl<T: TS, E: TS> TS for Result<T, E> {
     type WithoutGenerics = Result<Dummy, Dummy>;
+    type OptionInnerType = Self;
 
     fn name() -> String {
         format!("{{ Ok : {} }} | {{ Err : {} }}", T::name(), E::name())
@@ -803,6 +834,7 @@ impl<T: TS, E: TS> TS for Result<T, E> {
 
 impl<T: TS> TS for Vec<T> {
     type WithoutGenerics = Vec<Dummy>;
+    type OptionInnerType = Self;
 
     fn ident() -> String {
         "Array".to_owned()
@@ -848,6 +880,8 @@ impl<T: TS> TS for Vec<T> {
 const ARRAY_TUPLE_LIMIT: usize = 64;
 impl<T: TS, const N: usize> TS for [T; N] {
     type WithoutGenerics = [Dummy; N];
+    type OptionInnerType = Self;
+
     fn name() -> String {
         if N > ARRAY_TUPLE_LIMIT {
             return Vec::<T>::name();
@@ -900,6 +934,7 @@ impl<T: TS, const N: usize> TS for [T; N] {
 
 impl<K: TS, V: TS, H> TS for HashMap<K, V, H> {
     type WithoutGenerics = HashMap<Dummy, Dummy>;
+    type OptionInnerType = Self;
 
     fn ident() -> String {
         panic!()
@@ -946,6 +981,8 @@ impl<K: TS, V: TS, H> TS for HashMap<K, V, H> {
 
 impl<I: TS> TS for Range<I> {
     type WithoutGenerics = Range<Dummy>;
+    type OptionInnerType = Self;
+
     fn name() -> String {
         format!("{{ start: {}, end: {}, }}", I::name(), I::name())
     }
@@ -1078,6 +1115,8 @@ impl std::fmt::Display for Dummy {
 
 impl TS for Dummy {
     type WithoutGenerics = Self;
+    type OptionInnerType = Self;
+
     fn name() -> String {
         "Dummy".to_owned()
     }


Test patch:
diff --git a/ts-rs/tests/integration/optional_field.rs b/ts-rs/tests/integration/optional_field.rs
index c64cbac..2bd7762 100644
--- a/ts-rs/tests/integration/optional_field.rs
+++ b/ts-rs/tests/integration/optional_field.rs
@@ -87,3 +87,68 @@ fn inline() {
     let c = "c: number | null";
     assert_eq!(Inline::inline(), format!("{{ x: {{ {a}, {b}, {c}, }}, }}"));
 }
+
+type Foo = Option<i32>;
+type Bar<T> = Option<T>;
+
+#[derive(TS)]
+#[ts(export, export_to = "optional_field/", optional_fields)]
+struct OptionalStruct {
+    a: Option<i32>,
+    b: Option<i32>,
+
+    #[ts(optional = nullable)]
+    c: Option<i32>,
+
+    d: i32,
+
+    e: Foo,
+    f: Bar<i32>,
+
+    #[ts(type = "string")]
+    g: Option<i32>,
+
+    #[ts(as = "String")]
+    h: Option<i32>,
+}
+
+#[test]
+fn struct_optional() {
+    assert_eq!(
+        OptionalStruct::inline(),
+        format!(
+            "{{ a?: number, b?: number, c?: number | null, d: number, e?: number, f?: number, g: string, h: string, }}"
+        )
+    )
+}
+
+#[derive(TS)]
+#[ts(export, export_to = "optional_field/", optional_fields = nullable)]
+struct NullableStruct {
+    a: Option<i32>,
+    b: Option<i32>,
+
+    #[ts(optional = nullable)]
+    c: Option<i32>,
+
+    d: i32,
+
+    e: Foo,
+    f: Bar<i32>,
+
+    #[ts(type = "string")]
+    g: Option<i32>,
+
+    #[ts(as = "String")]
+    h: Option<i32>,
+}
+
+#[test]
+fn struct_nullable() {
+    assert_eq!(
+        NullableStruct::inline(),
+        format!(
+            "{{ a?: number | null, b?: number | null, c?: number | null, d: number, e?: number | null, f?: number | null, g: string, h: string, }}"
+        )
+    )
+}