specta-rs/tauri-specta

Generate a Typescript interface for compile-time validation of commands

anelson opened this issue · 5 comments

Thanks for your work on tauri-specta!

I'm a very experienced Rust programmer and a very inexperienced front-end programmer, trying to get a simple app going with Tauri. Apologies in advance if this is a question born out of ignorance.

The main appeal to me of using something like tauri-specta in my app is to allow me to specify all of the types and commands in Rust, and automatically get those types exposed in Typescript for the front-end, specifically so that the Typescript compiler can validate my front-end code against the Rust types at compile-time. I've got this working great for the most part, but there's still a gap: if I rename or delete a command, I can forget to update my Typescript code and only discover the error at runtime.

Here's a concrete example. Using tauri-specta I generated the following snippet from bindings.ts:

export const commands = {
async newSession() : Promise<SessionHandle> {
    return await TAURI_INVOKE("new_session");
},
async sendMessage(session: SessionHandle, message: string) : Promise<string> {
    return await TAURI_INVOKE("send_message", { session, message });
},
async checkApiKey() : Promise<null> {
    return await TAURI_INVOKE("check_api_key");
}
}

The command sendMessage used to be called chatCompletion; I refactored it in the Rust code and forgot to update the Typescript code:

import { commands } from "./bindings";
...
const response = await commands.chatCompletion(session, userMessage);

I was surprised that this will compile. I think the reason is that the commands var doesn't have any type information.

It would be great if there was an option to generate the following instead:

interface Commands {
  newSession: () => Promise<SessionHandle>;
  sendMessage: (session: SessionHandle, message: string) => Promise<string>;
  checkApiKey: () => Promise<void>;
}

export const commands: Commands = {
  newSession: async (): Promise<SessionHandle> => {
    return await TAURI_INVOKE("new_session");
  },
  sendMessage: async (
    session: SessionHandle,
    message: string,
  ): Promise<string> => {
    return await TAURI_INVOKE("send_message", { session, message });
  },
  checkApiKey: async (): Promise<void> => {
    return await TAURI_INVOKE("check_api_key");
  },
};

IIUC, this would be enough to get the Typescript compiler to realize that there is no chatCompletion command and thus fail at compile time.

Is there an existing way within tauri-specta to solve this problem? If not, and if you'd be open to a PR to add this functionality, I'd appreciate any hints you have regarding how you'd like to see it implemented and I can take a crack at it myself.

The current expectation is that when you change your Rust code you must rerun the Typescript exporter. You shouldn't adjust it manually as any changes will be lost when the exporter runs again.

The way I genrally do this is by putting the Typescript export function within main and then wrapping it in #[cfg(debug_assetions)] so that it doesn't export in a production build (example).

Then all your need to do is tauri dev and the entire bindings.ts file will be replaced with the updated one, triggering Typescript errors for any commands that were changed in any way.

Thanks for the quick response!

The current expectation is that when you change your Rust code you must rerun the Typescript exporter. You shouldn't adjust it manually as any changes will be lost when the exporter runs again.

Yeah I understand that, I have that exact code in my application. I haven't manually modified bindings.ts.

The problem is that the as-generated bindings don't trigger a compile error if I rename or delete a command in Rust and do not modify my Typescript code that uses the bindings (the bindings.ts file is of course re-generated and reflects the changes in the Rust code). Instead it fails at runtime. I'm not a Typescript expert but I think that's because the exported commands variable doesn't have any type information.

Is that not your experience using the generated bindings? I can post a minimal example if that will help clarify things...

Type mismatches can only be detected by running tsc or through your editor's LSP. For mismatched commands to trigger a compile error we'd need to export each command via export function ... instead of using a single object, but that would require generating multiple files which we don't want to do.
Changing commands to be defined via an interface won't affect anything - the commands object already has a type, it's just inferred from the object definition.
image

Got it. Sorry for ignorance about Typescript. There must be some issue in my Tauri build process then, since I'm not seeing errors like in your screenshot.

No worries, typescript can be annoying.
The error I showed was from inside my editor, it doesn't appear as part of my build process - I have a separate typecheck task that runs tsc for that.