Removing Tauri's Params Trait

  • July 16, 2021
  • 17 minute read
  • Rust
  • Tauri
Why we decided to remove Tauri's Params trait during beta even though it caused a breaking change.

In the latest beta release of Tauri (1.0.0-beta.5), we made a decision to remove the Params trait even though it causes a breaking change. As background, Params was added to the first beta release candidate to utilize user defined types for Tauri APIs along with an additional hope to support user defined types in future non-breaking features. The goal of user defined types was being able to have strong types, such as an enum, represent things like events or window labels instead of strings to catch mistakes during compilation. It helped prevent accidentally passing in a typo or non-existent window, but does not help logic errors such as passing in MyWindow::Primary when you meant MyWindow::Secondary.

Dropping Existing Params

If you are reading this article because you want to know how to remove Params from your codebase then you might be interested in one or both of the next sections. The most common way to use Params was in specifying it in trait bounds for your own functions/methods. The other way was supplying custom types to the builder. I will go over solutions to these in order.

Trait Bounds

If you used Params as a trait bound, then you likely have code that looks vaguely like the following:

fn send_init_event<P: Params<Event=String>>(window: &Window<P>) {
    window.emit("init", ());
}

All items that used Params as a generic now use Runtime. Additionally, they also implement a default Runtime if you are using wry (you probably are). This means that the simplest transformation for people using the default setup provided by Tauri is:

fn send_init_event(window: &Window) {
    window.emit("init", ());
}
Expand me if you have a custom Runtime

In the case that you do happen to be using a custom Runtime (which we would love to hear about), then either of the following should work fine.

fn send_init_event_direct(window: &Window<MyCustomRuntime>) {
    window.emit("init", ());
}

fn send_init_event_bounds<R: Runtime>(window: &Window<R>) {
    window.emit("init", ());
}

Custom Types

The second way to use Params was to pass custom types to Builder::new() to utilize custom types for events, window labels, and more. It is not necessary to remove these custom types completely, but they may now need to be converted to a string or string slice when passing them into API functions now. Additionally, you will need to drop them as arguments for the builder.

The way to write the builder if you are using wry is:

tauri::Builder::default()

Or, if you are using a custom runtime (which we would love to hear about):

tauri::Builder::new::<MyCustomRuntime>()

All the previous custom types had a hard requirement to be "string-able" which was enforced through Display and Serialize. The APIs that previously took owned values or references of the custom types now accept Into<String> and &str respectively. The breaking change may affect you if your types as they exist right now do not coerce into those types, so you may need to add some conversions during calls to the API.

The History of Params

If you just wanted to know how to update your Tauri application to 1.0.0-beta.5, then everything after this point is not necessary.

The Original Goals

These were the motivating factors for adding Params to tauri, not the motivating factors behind Tauri itself. I will refer to these later as the original goals.

  1. Ability to enforce the correct user defined type at compile time.
  2. Enable the use of lightweight types such as those implementing Copy.
  3. Better developer experience by allowing enums for types that have limited values.
  4. Allow expansion of custom types for future features without breaking changes.

What is Params?

For some context to Params, here is the trait definition:

/// Types associated with the running Tauri application.
pub trait Params: private::ParamsBase + 'static {
    /// The event type used to create and listen to events.
    type Event: Tag;

    /// The type used to determine the name of windows.
    type Label: Tag;

    /// The type used to determine window menu ids.
    type MenuId: MenuId;

    /// The type used to determine system tray menu ids.
    type SystemTrayMenuId: MenuId;

    /// Assets that Tauri should serve from itself.
    type Assets: Assets;

    /// The underlying webview runtime used by the Tauri application.
    type Runtime: Runtime;
}

Tag and MenuId represent string-able types, which is important to keep in mind in the future. The trait was sealed to allow us to expand (not change) the trait in the future without causing breaking changes. You had to use the builder to actually set the types, which became tedious over time. For example, the Default implementation:

/// Make `Wry` the default `Runtime` for `Builder`
#[cfg(feature = "wry")]
impl<A: Assets> Default for Builder<String, String, String, String, A, crate::Wry> {
    fn default() -> Self {
        Self::new()
    }
}

Builder was complex generic-wise due to its "you must declare everything at once" format. This complexity focused on the builder and left other items to only need to worry about having Params. Except, it kind of didn't. Users still needed to add a Params bound to their own functions and methods they were creating with the associated type that they were trying to use. Even more frustrating, it required it even for String even though it was the default.

// this application uses Builder::default()
fn say_hi_to_bob<P: Params<Event=String>>(window: &Window<P>) {
    window.emit("bob", "hi");
}

We just wanted to use the default types, why do I need to specify all this other stuff? If we omitted the <Event = String> portion and just used <P: Params> then the compilation would fail with something similar to:

error[E0308]: mismatched types
  note: expected type `<P as Params>::Event`
        found reference `&str`

Window::emit knows that it should take P::Event from the function definition, but it doesn't know the concrete type that it should resolve to. For every method used that uses an associated type, the concrete type needs to be specified in order to compile. If the say_hi_to_bob function also contained methods that used the window label and menu id, you can see how it gets tedious quickly:

// this application uses Builder::default()
fn say_hi_to_bob<P: Params<Event=String, Label=String, MenuId=String>>(window: &Window<P>) {
    window.emit("bob", "hi");
    window.emit_to("main", "bob", "hi");
    window.on_menu_event(|_menu_event| {
        // something
    });
}

This was mitigated somewhat in 1.0.0-beta.0 when we added a default type to all the items taking Params. It allowed omitting the generic completely if you used the default types. Custom types still needed to be listed explicitly, leaving some signatures very complex. There is still a trick to reduce verbosity in this case though:

// Event, Window, Menu, and SystemMenu are existing custom types
trait Params:
tauri::Params<Event=Event, Label=Window, MenuId=Menu, SystemTrayMenuId=SystemMenu>
{}

impl<P> Params for P where
    P: tauri::Params<Event=Event, Label=Window, MenuId=Menu, SystemTrayMenuId=SystemMenu>
{}

You could then use that helper trait around your application code and only need to worry about specifying the types in the trait definition instead of throughout your application. So why was Params still problematic? Sure it was verbose in the builder and your trait helper, but was that really enough to remove it? From a code perspective, this was solved. We had solutions for most of the verbosity problems encountered during development, but an equal or greater problem was the mental complexity it introduced. For users unfamiliar with Rust, and sometimes newer to programming in general, this was a massive pain point. A user could use a &String or a &str with window.emit(...) in their code but figuring that out from the signatures was not easy. For example, here is the same method with and without Params:

// without Params
fn emit_to<S>(&self, label: &str, event: &str, payload: S) -> Result<()>
    where S: Serialize + Clone
{
    self
        .manager()
        .emit_filter(event, payload, |w| label == w.label())
}

// with Params
fn emit_to<E: ?Sized, L: ?Sized, S: Serialize + Clone>(
    &self,
    label: &L,
    event: &E,
    payload: S,
) -> Result<()>
    where
        P::Label: Borrow<L>,
        P::Event: Borrow<E>,
        L: TagRef<P::Label>,
        E: TagRef<P::Event>,
{
    self
        .manager()
        .emit_filter(event, payload, |w| label == w.label())
}

The signature when using Params may be somewhat familiar to those who know HashMap as it works off a similar concept. E and L accept a type that is a reference to the specific owned custom type. Like HashMap::get, the function would accept &str if the Tag (Event, Label) was String - along with allowing similar mechanics for custom types. This is not clear to people unfamiliar or new to Rust and causes some unnecessarily complex signatures in the documentation that many users find difficult to grok. Having multiple of these bounds per signature only added to the confusion.

So what about having these custom types only on items they affect and dropping Params if it wasn't working out well? Unsurprisingly, this was the first approach but quickly grew unmanageable as most items required all the generics due to the flexibility of the API. This flexibility comes in many forms, the core of which involves allowing a custom webview runtime to be set (we provide wry by default).

The Tauri Runtime is a layer between Tauri and the underlying webview runtime. It provides the core traits that enable us to pass messages to the webview runtime without worrying about the underlying webview runtime or platform. Because of this, it is up to the underlying webview runtime to implement those traits for whatever platforms they want to support. Thus, the Runtime trait purpose can be simplified to a "cross-platform message dispatcher to the native platform." This is why the Runtime trait appears on most items that can interact with the webview, such as Window, App, or Invoke.

Similar to needing Runtime when sending messages to the native webview runtime, types that need to use a user defined type require the generic somewhere. Additionally, if it holds another type that uses other user defined types then it needs those generics too! Effectively, all of them were needed everywhere most of the time due to types "infecting" other types. This is the problem that Params solved for the original goals.

Correcting the Course for the Future

We're still motivated by the factors described in the original goals, so how do we achieve them without Params or making a mess of generics in the tauri crate? This is an article about the removal of Params, so we have already found our way forward, but what drove the decision?

Downsides of the Original Goals

What did our implementation of the original goals with Params make us compromise in order to achieve them?

Complicated API

The resulting API was much too complex for most Rust beginners and some intermediate users. An important goal for Tauri is to be welcoming to newcomers in the community and ecosystem. Due to our stack enabling native applications with web technology, we naturally see a lot of developers who aren't familiar with Rust but know JavaScript/TypeScript. Many are already familiar with other platforms that enable cross-platform desktop applications built with web technology, such as Electron, and are interested in some benefits that Tauri offers.

Maintenance

This is the downside that actually sparked the Pull Request that removed Params. While not more or less important than other downsides, it was the cause for us to re-evaluate the implementation of the original goals. The internal code had turned complex in a number of places alongside triggering lints like clippy::type_complexity. Places where we accepted references to custom types had many bounds to consider and any type that used Params had to deal with the associated types. Code that parsed strings into the custom types were also surrounded by boilerplate error handling to panic if the custom type FromStr implementation didn't handle unknown string internally.

A small metric to show some of this internal code complexity is the line change count from the Pull Request that removed Params. There were 2,264 lines changed, broken down into +855 -1,409. That represents roughly 40% less code in places that had to deal with Params. Keep in mind that lines of code is a terrible metric and doesn't represent quality, only quantity.

Re-evaluating

As we had internal discussion on ways to shed all this complexity and still achieve the original goals, a core idea took hold - these strong user defined types do not need to be part of the tauri crate. In fact, all the goals can be more-or-less effectively implemented by another higher level crate which wraps the core.

This is because of the underlying Tauri Runtime requirements which use strings to pass messages. If we store custom types in core, then at some point we have to turn it into a string to pass it down to the underlying runtime. If we forgo inserting custom types into core and settle on strings, we can simplify the exposed core API while still allowing at a higher level to use better types. A few immediate benefits of only using strings is a much simpler API and less complex code to deal with in core. Do we lose any benefits? Not really.

Let's talk about the first goal, enforcing the correct type at compile time. A higher level crate can just as effectively enforce this by wrapping the current core API. This is effectively moving the Params trait out of core and into a separate crate and handling all strong typing there. A (maybe) surprising benefit of this is that even if Params itself stays complex in this new higher level crate, it is self-contained and does not need to be handled by the core itself. Additionally, there are other patterns that would allow us to expose it unsealed (AKA users can implement the trait) to prevent the headache of providing a private concrete type for the sealed trait. Part of this is possible by allowing the higher level crate to be less stable than tauri itself, allowing for more API evolution. The stability of the core crate tauri is of utmost importance, so we wish to avoid excessive major changes following the third-party security audit of the core codebase.

The second goal is about enabling the use of lightweight types. This means type that can be passed relatively efficiently with Copy, such as enums that only contain Copy types. This turned out to not matter so much in the core because as previously stated, at some point during storage the type has to be converted to a string of some form. A higher level crate still allows for Copy types in the application code while still handling them as strings in core.

The third goal is about allowing a better developer experience by using Rust's typing system to handle more things. In the most common case, this is about being able to use Rust's great enums to limit the allowed values of events, window labels, and menu items. A higher level crate could still provide this benefit.

Do you see the theme here?

The final goal is about expanding strong types for future non-breaking Tauri features. Technically that point was about also providing the strong typing in a non-breaking manner, but let's forget about that for a minute. While discussing the first goal, we mentioned that the stability of the higher level crate being acceptably less stable than tauri core. Additionally, there were no concrete plans of how to expand the builder and Params in a non-breaking way for new features as we had not reached that point. So we are accepting the less strict stability requirements for satisfying goal number four. There are also options in the higher level crate to provide non-breaking strongly typed APIs in various manners.

To recap, a higher level crate can provide an acceptably less-stable API to perform the same compile-time type checking. tauri core can stay the same, which is beneficial for the audit that will be performed on it.

I've been referring to the concept of stronger type checking in another crate as a higher level crate. This "higher level crate" does not currently exist as an available library, but may soon exist in the future based on lessons we've learned with current strong typing mechanics. Nevertheless, these concepts do not need to be exposed as a higher level crate in order to take advantage of the stronger typing.

Stronger Typing, Now

Here I will cover various methods that you can take advantage of directly in your own application code without needing a higher level crate(a higher level crate would make it easier, however). The methods require a variety of Rust knowledge, but from this point on I will assume you have read and understood The Rust Book along with a fair amount of practice.

Converting at the Boundaries

We mentioned in the custom types section that existing custom types would still work, but may need to be converted to a more suitable type beforehand. I will showcase how this could work now with the following example of a custom event type.

#[derive(Copy, Clone)]
enum MyEvent {
    Start,
    Restart,
    Stop
}

Previously we took Display, so any type that implemented it would be accepted. Now, we have Into<String>, meaning we should instead implement From to enable the conversion. The implementation may look something like this.

impl From<MyEvent> for String {
    fn from(event: MyEvent) -> String {
        match event {
            MyEvent::Start => "start",
            MyEvent::Restart => "restart",
            MyEvent::Stop => "stop",
        }.into()
    }
}

This implementation just creates a String from a &'static str in order to create a string from a value of the enum on-demand. In places that previously took P::Event that now take Into<String>, we can now use the owned value of the type directly. You would also be able to use references to these events if wished by additionally implementing impl From<&MyEvent> for String. In a similar vein, you can implement AsRef<str> to make it easy to create borrowed strings in places that now take &str.

impl AsRef<str> for MyEvent {
    fn as_ref(&self) -> &str {
        match self {
            MyEvent::Start => "start",
            MyEvent::Restart => "restart",
            MyEvent::Stop => "stop",
        }
    }
} 

Now, if we have an event to pass into e.g. window.event(&my_event, "message"), we can instead call window.event(my_event.as_ref(), "message").

This method can help you continue to use existing types in your codebase, but still relies on you not accidentally converting the wrong item that implements Into<String> when passing them to tauri APIs. An example is accidentally passing an event where you meant to pass a window label.

Other

This section may expand in the future with more in-depth examples. These examples will more closely align to methodologies that will be tried out in the "higher level" crate. The two expected to be explored currently is extending the tauri core types with additional traits, and wrapping the entirety of the core in new strongly typed items.

I was originally planning on showcasing those concepts here in this article, but I found myself consistently expanding the sections with more and more code along with explanations alongside them. With so much code needed, I believe it more wise to first write them out completely in an experimental crate with fleshed out documentation and perhaps an accompanying blog post. I will try to remember to add links to them here when they are done.