Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RFC: Add support for exporting functions #249

Draft
wants to merge 16 commits into
base: main
Choose a base branch
from

Conversation

escritorio-gustavo
Copy link
Collaborator

@escritorio-gustavo escritorio-gustavo commented Mar 4, 2024

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

@escritorio-gustavo
Copy link
Collaborator Author

Also might generate something like

type foo = (bar: string, baz: number) => boolean

@escritorio-gustavo
Copy link
Collaborator Author

escritorio-gustavo commented Mar 4, 2024

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"

@escritorio-gustavo
Copy link
Collaborator Author

I have changed args to be "inlined" (old "named") or "flattened" (old "positional")

@escritorio-gustavo
Copy link
Collaborator Author

escritorio-gustavo commented Mar 5, 2024

Still not sure on the names though...

Maybe "normal" and "destructured"?

@escritorio-gustavo
Copy link
Collaborator Author

This attribute doesn't require export, as it assumes that if you use it, you want to export it

@escritorio-gustavo
Copy link
Collaborator Author

It also requires TS to be in scope

@escritorio-gustavo escritorio-gustavo changed the title Add support for exporting functions RFC: Add support for exporting functions Mar 5, 2024
@NyxCode
Copy link
Collaborator

NyxCode commented Mar 6, 2024

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.

@escritorio-gustavo
Copy link
Collaborator Author

escritorio-gustavo commented Mar 6, 2024

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

@escritorio-gustavo
Copy link
Collaborator Author

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

@escritorio-gustavo
Copy link
Collaborator Author

escritorio-gustavo commented Mar 6, 2024

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

@NyxCode
Copy link
Collaborator

NyxCode commented Mar 6, 2024

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?

@NyxCode
Copy link
Collaborator

NyxCode commented Mar 6, 2024

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.

@escritorio-gustavo
Copy link
Collaborator Author

That might be interesting! We will also need an extra import in the file to do this

import { invoke } from "@tauri-apps/api"

@NyxCode
Copy link
Collaborator

NyxCode commented Mar 6, 2024

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.

@escritorio-gustavo
Copy link
Collaborator Author

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

@escritorio-gustavo
Copy link
Collaborator Author

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

@NyxCode
Copy link
Collaborator

NyxCode commented Mar 10, 2024

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.

Footnotes

  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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request Next Major Release
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Add support for functions
2 participants