Aleph-Alpha / ts-rs

Generate TypeScript bindings from Rust types
MIT License
989 stars 99 forks source link

Feature request: allow method of including `[propName: string]: any` to type #335

Closed murl-digital closed 1 week ago

murl-digital commented 1 week ago

i have a struct with a generic i'm exporting as a typescript type, however due to this struct being a part of a library where the generic will be provided by the user, there's not really a good way to define a type. since it's flattened, what i'd want to do is include a [propName: string]: any to the end like the typescript docs recommend, but i don't see a clean way to do that. here's the closest i've been able to get:


#[derive(TS)]
pub struct AnyMarker {
    #[ts(type = "any")]
    foo: ()
}

impl Document for AnyMarker {
    fn identifier() -> &'static str {
        todo!()
    }

    fn title() -> &'static str {
        todo!()
    }

    fn fields() -> Vec<EditorField> {
        todo!()
    }

    fn validators(model: DataModel) -> HashMap<String, ValidatorFunction> {
        todo!()
    }
}

#[derive(Serialize, Deserialize, TS)]
#[ts(export)]
#[ts(concrete(D = AnyMarker))]
pub struct Item<D: Document> {
    #[serde(rename = "__sc_id")]
    pub id: String,
    #[serde(rename = "__sc_created_at")]
    pub created_at: DateTime<Utc>,
    #[serde(rename = "__sc_modified_at")]
    pub modified_at: DateTime<Utc>,
    #[serde(rename = "__sc_published_at")]
    pub published_at: Option<DateTime<Utc>>,
    #[serde(flatten)]
    pub inner: D,
}
NyxCode commented 1 week ago

Hey! I'm not sure I fully understand what you're trying to do yet. What TS would you like to get?

NyxCode commented 1 week ago

We do not support generating these "indexable" types, since there is no real rust equivalent.

If you really need to have a [propName: string]: any field, then you could add that field with some TypeScript.

type Item = ItemData & { [propName: string]: any };

You could also force ts-rs to generate that for you:

#[derive(ts_rs::TS)]
#[ts(export)]
struct Item {
  id: String,
  #[ts(flatten)]
  props: Props,
}

struct Props;
impl ts_rs::TS for Props {
   type WithoutGenerics = Self;
   fn decl() -> String { unreachable!() }
   fn decl_concrete() -> String { unreachable!() }
   fn name() -> String { unreachable!() }
   fn inline() -> String { unreachable!() }
   fn inline_flattened() -> String { "{ [propName: string]: any }".to_owned() }
}

But this is really abusing the library.
Again, I don't yet understand your use-case, so I can't recommend any alternatives.

NyxCode commented 1 week ago
type Item<D> = { name: string, /* ... */ } & D;

If this what you'd like it to generate?

murl-digital commented 1 week ago

Hey! I'm not sure I fully understand what you're trying to do yet. What TS would you like to get?

basically, what i'm trying to achieve is a type that defines an object with a few set fields, and then any field after that. for example, here's what a JSON representation of an Item would look like:

{
  "__sc_id": "YFCzT4NxQEjYxjBvO_zCc",
  "__sc_created_at": "2024-06-27T20:23:30.217589619Z",
  "__sc_modified_at": "2024-06-27T20:23:30.217589619Z",
  "__sc_published_at": null,
  "hi": "hello world!",
  "number": 1,
  "test": {
    "type": "Unit"
  }
}

and here's how the D (a struct called Test) is defined:

struct Test {
    pub hi: String,
    pub number: i32,
    #[field(validate)]
    pub test: TestEnum,
}

#[doc_enum]
#[derive(Clone)]
enum TestEnum {
    Unit,
    Struct { eeee: String },
}

the reason i asked for [propName: string]: any comes from finding this page in the TypeScript docs, in practice i think the cleanest way of allowing this would be some kind of #[ts(rest)] attribute

the specific application this is for is a cms, where the shape of the user's data isn't known at type generation time, and the type safety's guartunteed elsewhere by a schema

edit: typos and formatting

NyxCode commented 1 week ago

I see!
What #336 would allow you is to keep your Item type generic, so you'll end up with type Item<D> = { /* ... */ } & D.
That seems like a more type-safe option than just allowing any additional fields.

Please let me know if that fits your use-case. If not, and you actually do need to allow for arbitrary additional fields using [anythingElse: string]: any, we'll need to see if there's something we can do about that, or if the hack I outlined above is good enough.

murl-digital commented 1 week ago

i'm not fully convinced the generics flattening fit in my use case. the problem is that the concrete type will be provided by the end users, and i don't think it's a good idea to force them to derive (then export) the TS type to make them useful in the editor i'm working on. i do think that doing the hack you offered would be OK, because my usecase is ultra-specific and definitely comes with a healthy amount of "i know what i'm doing". whether or not you want to make a more elegant way to achieve that is up to you.

gustavo-shigueo commented 1 week ago

we'll need to see if there's something we can do about that, or if the hack I outlined above is good enough.

I wouldn't even call it a hack, if the issue is having a generic type that is unknown to Rust be flattened and let TS figure out the generic, having & D is a much better approach than [x: string]: any, as the latter will absolutely kill TS's ability to warn you about accessing a property that doesn't exist (e.g. having a typo in a property name)

NyxCode commented 1 week ago

having & D is a much better approach than [x: string]: any, as the latter will absolutely kill TS's ability to warn you about accessing a property that doesn't exist

That's true! I suspect having a [x: string]: any field inside your type makes it roughly as usefull as just any. You wont get any warnings if you write to an unknown field, and you wont get any warnings if you read an unknown field.

NyxCode commented 1 week ago

Anyway, If you do go with [x: string]: any, then I'd recommend that you write that in TypeScript (type Item = ItemData & {[x: string]: any} where ItemType is what ts-rs generates for you).
Then you wont have a breakage if we touch the TS trait, which we do regularly (which is why we're at version 9.0 already ^^)

murl-digital commented 1 week ago

is there a good way to address the trait bound TS wants for the generic? right now i'm getting this compiler error, which runs up against me not wanting to force end users to generate typescript types

error[E0277]: the trait bound `D: TS` is not satisfied
   --> scalar/src/lib.rs:69:17
    |
67  | #[derive(Serialize, Deserialize, TS)]
    |                                  -- required by a bound introduced by this call
68  | //#[ts(export)]
69  | pub struct Item<D: Document> {
    |                 ^ the trait `TS` is not implemented for `D`
    |
note: required by a bound in `visit`
   --> /home/draconium/.cargo/registry/src/index.crates.io-6f17d22bba15001f/ts-rs-9.0.0/src/lib.rs:581:17
    |
581 |     fn visit<T: TS + 'static + ?Sized>(&mut self);
    |                 ^^ required by this bound in `TypeVisitor::visit`
help: consider further restricting this bound
    |
69  | pub struct Item<D: Document + ts_rs::TS> {
NyxCode commented 1 week ago

There's #[ts(bound)]. If you share what you ended up with, i'd be happy to take a look as well.

NyxCode commented 1 week ago
#[derive(ts_rs::TS, serde::Serialize)]
#[ts(export, concrete(D = AnythingElse))]
struct Item<D> {
  id: String,
  #[serde(flatten)]
  inner: D,
}

struct AnythingElse;
impl ts_rs::TS for AnythingElse {
   type WithoutGenerics = Self;
   fn decl() -> String { unreachable!() }
   fn decl_concrete() -> String { unreachable!() }
   fn name() -> String { unreachable!() }
   fn inline() -> String { unreachable!() }
   fn inline_flattened() -> String { "{ [propName: string]: any }".to_owned() }
}

this here should should just work as-is

murl-digital commented 1 week ago

yeah that suggestion works, if you want, you could include the type in ts_rs as IndexibleAny or something, with a stern warning in the docs saying why in most cases it's probably a bad idea

gustavo-shigueo commented 1 week ago

right now i'm getting this compiler error, which runs up against me not wanting to force end users to generate typescript types

You could add a cargo feature to your library that gates the use of ts-rs, this way users that want to export TS enable the feature and get type safety, while users that don't just don't need to worry about implementing TS

NyxCode commented 1 week ago

yeah that suggestion works, if you want, you could include the type in ts_rs as IndexibleAny or something, with a stern warning in the docs saying why in most cases it's probably a bad idea

Great! I'd rather not include this, since it's really not something I'd recommend. GitHub issues are nicely searchable, so if someone stumbles across this, I hope they find this issue.