Repository: 1Password/typeshare (rust)

Issue:
Double option pattern support
Hello,
I found a missing feature in `typeshare`. Some APIs support partial updates for DTOs. That means that they have a distinction between a missing field and a field equal to `null` eg.:

```rust
#[derive(Serialize, Deserialize, Debug)]
#[typeshare]
pub struct UserUpdateParams {
    pub username: Option<String>,
    pub password: Option<String>,
    #[typeshare(serialized_as = "Option<String>")]
    pub valid_until: Option<OffsetDateTime>,
}
```

When the `valid_until` property is `null` on the database level, it means that the user is valid forever. To support this kind of use case `serde` has a [`double option`](https://docs.rs/serde_with/latest/serde_with/rust/double_option/) pattern.

Unfortunately this is not supported by `typeshare` since `Option<Option<String>>` is compiled as `valid_until?: string` in `TypeScript`. Instead it should be compiled as `valid_until?: string | null`.

Pull request description:
Support the double option pattern for TypeScript
Double option pattern is used for partial updates of optional fields. See the [serde_with helper](https://docs.rs/serde_with/latest/serde_with/rust/double_option/) for more details.

Closes #59.

Patch:
diff --git a/core/src/language/typescript.rs b/core/src/language/typescript.rs
index 5ad5b4f..5e8b37e 100644
--- a/core/src/language/typescript.rs
+++ b/core/src/language/typescript.rs
@@ -219,12 +219,14 @@ impl TypeScript {
             .format_type(&field.ty, generic_types)
             .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
         let optional = field.ty.is_optional() || field.has_default;
+        let double_optional = field.ty.is_double_optional();
         writeln!(
             w,
-            "\t{}{}: {};",
+            "\t{}{}: {}{};",
             typescript_property_aware_rename(&field.id.renamed),
             optional.then(|| "?").unwrap_or_default(),
-            ts_ty
+            ts_ty,
+            double_optional.then(|| " | null").unwrap_or_default()
         )?;
 
         Ok(())
diff --git a/core/src/rust_types.rs b/core/src/rust_types.rs
index f7eb523..dc0e7ae 100644
--- a/core/src/rust_types.rs
+++ b/core/src/rust_types.rs
@@ -272,6 +272,16 @@ impl RustType {
     pub fn is_optional(&self) -> bool {
         matches!(self, Self::Special(SpecialRustType::Option(_)))
     }
+
+    /// Check if the type is `Option<Option<T>>`
+    pub fn is_double_optional(&self) -> bool {
+        match &self {
+            RustType::Special(SpecialRustType::Option(t)) => {
+                matches!(t.as_ref(), RustType::Special(SpecialRustType::Option(_)))
+            }
+            _ => false,
+        }
+    }
     /// Check if the type is `Vec<T>`
     pub fn is_vec(&self) -> bool {
         matches!(self, Self::Special(SpecialRustType::Vec(_)))


Test patch:
diff --git a/core/data/tests/can_generate_double_option_pattern/input.rs b/core/data/tests/can_generate_double_option_pattern/input.rs
new file mode 100644
index 0000000..62b1a29
--- /dev/null
+++ b/core/data/tests/can_generate_double_option_pattern/input.rs
@@ -0,0 +1,4 @@
+#[typeshare]
+pub struct SomeStruct {
+    field_a: Option<Option<u32>>,
+}
diff --git a/core/data/tests/can_generate_double_option_pattern/output.ts b/core/data/tests/can_generate_double_option_pattern/output.ts
new file mode 100644
index 0000000..790f35f
--- /dev/null
+++ b/core/data/tests/can_generate_double_option_pattern/output.ts
@@ -0,0 +1,8 @@
+/*
+ Generated by typeshare 1.0.0
+*/
+
+export interface SomeStruct {
+	field_a?: number | null;
+}
+
diff --git a/core/tests/snapshot_tests.rs b/core/tests/snapshot_tests.rs
index ea26031..4c3ea34 100644
--- a/core/tests/snapshot_tests.rs
+++ b/core/tests/snapshot_tests.rs
@@ -359,6 +359,9 @@ tests! {
         go
     ];
     can_generate_bare_string_enum: [swift, kotlin, typescript];
+    can_generate_double_option_pattern: [
+        typescript
+    ];
     test_simple_enum_case_name_support: [swift, kotlin, typescript];
     test_algebraic_enum_case_name_support: [
         swift {