RFC: Add support for exporting functions
This PR aims to add support for exporting function signatures through ts-rs. Everything here is a work in progress and probably needs some heavy refactoring.
The idea is to create an attribute macro to be used on a function such as
#[ts_rs_fn(args = "positional")]
fn foo(bar: String, baz: u32) -> bool {
// ...
}
And have the following signature be generated:
export type FooFn = (bar: string, baz: u32) => boolean
#[ts_rs_fn(args = "named")]
fn foo(bar: String, baz: u32) -> bool {
// ...
}
export type FooFn = (args: { bar: string, baz: u32 }) => boolean
...writing this I now realize "named" and "positional" are terrible names, but as I said, work in progress
Closes #92
Also might generate something like
type foo = (bar: string, baz: number) => boolean
My thinking is that we need to generate a struct which has fields corresponding to the function's arguments. This struct must derive TS and then we generate something like
quote!(export type #ident = (#flattenned_struct) => #return_ty) for "positional"
or
quote!(export type #ident = (args: #inlined_struct) => #return_ty) for "named"
I have changed args to be "inlined" (old "named") or "flattened" (old "positional")
Still not sure on the names though...
Maybe "normal" and "destructured"?
This attribute doesn't require export, as it assumes that if you use it, you want to export it
It also requires TS to be in scope
Interesting!
I haven't thought about this idea as much as I should have, so I'm still a bit fuzzy on what the concrete use-cases are.
What libraries / frameworks do we have in mind here, and what would users do with these type Something = <some function> types?
For methods, we could include the methods in the the type we already emit for them. But there, I'm again a bit fuzzy on the concrete use-case. For an API, for example, the methods I'll want client-side will be very different. And just deserializing into that type wont yield any methods anyway, and it'd require a wrapper class or something in that vein.
For wasm, users already get TS bindings for their functions, though the parameters will be primitives or Strings, and the (de)serialization step when calling wasm functions has to be done manually. More type safety might be nice there, though I'm not sure how this would fit in there.
For tauri, I'm completely oblivious how this works there, so I'd appreciate if you could give me an overview there.
What libraries / frameworks do we have in mind here
Honestly, not entirely sure, mostly Tauri I think.
and what would users do with these type Something =
types?
Most likely something along the lines of
const myFunction: Something = <some function implementation>
For tauri, I'm completely oblivious how this works there, so I'd appreciate if you could give me an overview there.
So tauri exposes a function called invoke to the frontend, which is able to call any Rust function marked as #[tauri::command] the backend exposes.
The problem is, the definition of invoke is something along the lines of:
declare function invoke<T>(command: string, args: Record<string, unknown>): Promise<T>
Where command is the Rust function's name, written exactly the same as in Rust, and args is an object containing all of its arguments, but renamed to camelCase. There is pretty much no intellisense or LSP help at all when using it directly.
This proposal could potentially allow for:
#[ts_rs::tr_rs_fn(args = "inlined", rename_all = "camelCase")] // This "inlined" name is still a WIP
#[tauri::command]
async fn my_command(my_arg: u8) -> String {
String::new()
}
// Generated file:
type MyCommand = (args: { myArg: number, }) => Promise<string>
// User's code
const myCommand: MyCommand = args => invoke('my_command', args) // Still needs to type 'my_command' :/
myCommand({ myArg: 42 }) // This gets LSP support :D
For wasm, users already get TS bindings for their functions, though the parameters will be primitives or
Strings, and the (de)serialization step when calling wasm functions has to be done manually. More type safety might be nice there, though I'm not sure how this would fit in there.For tauri, I'm completely oblivious how this works there, so I'd appreciate if you could give me an overview there.
I'm the other way around, I use Tauri a lot, but I have no idea how wasm works lol
For methods, we could include the methods in the the type we already emit for them. But there, I'm again a bit fuzzy on the concrete use-case. For an API, for example, the methods I'll want client-side will be very different. And just deserializing into that type wont yield any methods anyway, and it'd require a wrapper class or something in that vein.
I'm pretty sure methods wouldn't work with this (or at least I've got no idea how to handle them - just discard self?), so I focused more on regular functions here.
Anyway, I do agree with you, the use cases for this are pretty narrow, even Tauri would struggle to benefit from it as you'd still have to write the command's name without any intellisense checking for typos.
I just saw the issue and decided to play around with it to see if I could come up with something useful, but exporting a Rust function's signature really does seem like a somewhat useless feature outside of slightly helping with Tauri, so I can see this either being dropped or feature gated if added
Maybe we could start with something tauri-specific? If the only focus was on tauri, we might be able to generate everything a user would want:
export function my_command(args: { myArg: number, }): Promise<string> {
return invoke('my_command', args);
}
Not sure how to nicely set that up, though. Maybe it'd make sense to have the core functionality (like you've already done) inside ts-rs, and then have a small wrapper crate ts-rs-tauri, which does the tauri-specific stuff on top?
To illustrate this idea, here's an example of how this could work:
ts-rs could expose #[ts(function)], which would generate a struct implementing TSFn or something like that. The trait would expose the arguments and the return type.
Then, tauri-ts-rs could take that struct we generated, get everything it needs through the TSFn trait, and generate tauri-specific code.
That might be interesting! We will also need an extra import in the file to do this
import { invoke } from "@tauri-apps/api"
Right! Also, arguments or return types might need to be imported.
This is why I think we need some runtime representtion of a function, just like TS is our runtime representation of a type, in order to do import resolution.
Also, arguments or return types might need to be imported.
I think the current state of the PR handles that already.
Another weird edge case is that invoke always returns a Promise even if the Rust function isn't async, so we may need to add an async flag to the attribute
Another thing, currently, this PR depends on DerivedTS, which belongs to ts_rs_macros, if we break some of it out into a separate crate, that type will be impossible to access, due to the fact that proc-macro crates can't have anything pub other than proc-macros, so we might need to create a ts_rs_core to handle that.
We could have all the logic in ts_rs_macros moved into ts_rs_core and have the macros crate only contain the macro entry points
So, sorry for this, that got way to long.
All of it is just an idea, and none of it is anywhere near fleshed out. Please, let me know what you think - is any of this reasonable? Any problems you see with going in that direction?
Like the direction you're going in here, we could implement generating function signatures in ts-rs-macros.
Everything beyond that (e.g generating tauri invoke or wasm/serde_json boilerplate) could live in a separate crate.
So far we're on the same page i think, and now the interesting question is: How do these crates do their "extra stuff"?
Instead of these separate crates being big proc-macro colossus, i think it'd be pretty awesome if their proc-macros were minimal, or if they could be implemented completely without proc macros.
In ts-rs, I think we do a lot of stuff currently in the proc macros that would be way cleaner if done during runtime and not during proc-macro time, though we're slowly moving towords that (e.g we introduced TypeList, which enabled awesome stuff like recursive exporting of dependencies).[^1]
Now, for these separate crates to do anything meaningfull, they need a good description of the function signature.
I think of this as a form of reflection - The macro generates just a description, and during runtime, we turn that description of the type/function into TypeScript.
In that vein, #[ts_rs_fn] could generate a struct implementing a new trait, e.g TsFn, which would only be a description of the function signature. Maybe it could look something like this:
trait TsFn {
const ASYNC: bool;
const NAME: &'static str;
type ReturnType: TS;
fn arguments() -> impl TypeList;
}
With that description in hand, the rest of the work could (at some point ^^) be done completely outside of the proc macro in the "frontend" crates.
I've got no idea what the best API for this could be, but there are a couple of options. For example, ts-rs-tauri could look like this:
trait TauriCommand: TsFn {
fn generate() -> String;
}
impl<F> TauriCommand for F where F: TsFn {
fn generate(export: bool, inline_args: bool) -> String {
let sync = if Self::ASYNC { "async " } else { "" };
// ...
let export = if export { "export" } else { "" };
format!("\
{export} {sync} function {name}({args}): {return_type} {{ \
return invoke('{name}'); \
}\
")
}
}
All this is definetely a long-term vision, and we'll need to find small steps to get us there.
As part of that journey, I think we'll want to slowly but surely make the TS trait more powerfull by moving logic from the proc-macro into the core crate.
[^1]: I kinda regret that, when starting this library, most of the work was done within the proc macro itself.
It started as "let's just write a proc macro for it", but then I realized that doing everything there means re-implementing parts of the compiler (e.g name resolution), so the runtime component was more of an afterthought.
I'll close this one as it really does need way more thought to be put into it than I initailly thought and I think the CLI should be our priority for now