cargo-semver-checks v0.35 can determine whether Rust traits are "sealed", allowing it to catch many tricky new instances of SemVer breakage. Why is accurate sealed trait detection so important, and why is implementing it correctly so hard?

Say our library contains the following trait:

pub trait Example: Super {
    fn frobnicate(x: crate::defs::Value);
}

Our coworker proposes to expand the trait with a new method:

 pub trait Example: Super {
     fn frobnicate(x: crate::defs::Value);

+    // Enables new functionality in our app.
+    fn tweak();
 }

Is this a major breaking change?

The answer: "it depends." It might be breaking, or it might not be — we can't tell yet. This essay digs into why that simple question is so tricky to answer. Spoiler: it depends on whether the Example trait is "sealed" — downstream crates cannot create their own implementations of sealed traits.

Good news though: cargo-semver-checks users can rest easy! In its latest release, cargo-semver-checks has become a world-class expert at applying these rules in fractions of a second. It's both faster and more accurate than humans could ever be!

Using cargo-semver-checks means we don't all have to invest our skill points into the minutia of SemVer in Rust. Let automation take care of it!

Adding new items to traits without breakage

Removing items from a public trait is clearly breaking: any code that used those items is broken. It's a bit less obvious why adding items to a trait might be breaking — so let's break that down here 🥁

Adding items to a trait may break downstream crates by breaking implementations of those traits. And thus, we take our first steps down the rabbit hole:

Let's answer both questions in turn.

New items without default values force implementation changes

The new tweak() trait function could have included a default implementation:

 pub trait Example: Super {
     fn frobnicate(x: crate::defs::Value);

+    // Enables new functionality in our app.
+    fn tweak() {
+        // some default implementation here
+        // which can optionally be overridden as needed
+    }
 }

If that were the case, the change would definitely have been non-breaking. Any implementations that lack a tweak() definition will automatically use the default defined by the trait.

Most trait items can have default values: trait constants support them on stable Rust, while trait associated types require nightly. Last I checked, some soundness issues were still being worked on here, so the implementation isn't ready for stabilization yet. Stay tuned!

Unfortunately, it's often impossible to define a reasonable default implementation. Our case doesn't have one, so we must keep looking.

Sealed traits cannot be implemented in downstream crates

Rust traits can be created in a way that ensures no downstream crate can create new implementations of those traits. If there are no downstream implementations, nobody can be broken by needing to amend them. The term for such traits is "sealed traits."

Sealed traits are a deep enough topic that I've written a guide covering their nuances. This essay will summarize the relevant rules as they become relevant while leaving the full details to the guide.

Our question then boils down to: Is the Example trait sealed?

Watch your step as we move deeper down the rabbit hole. It's a long way down.

Sealing traits via tricky function signatures

One way to seal a trait is to make it impossible to write a required function signature in a downstream crate.

As a reminder, our trait was originally defined as:

pub trait Example: Super {
    fn frobnicate(x: crate::defs::Value);
}

The trait doesn't provide a default value for frobnicate(), so all implementations are required to implement it themselves. If the crate that defines the trait is called upstream, a downstream crate would have had to implement the trait like so:

impl upstream::Example for MyType {
    fn frobnicate(x: upstream::defs::Value) {
        // some implementation here
    }
}

We notice that writing the function signature requires naming the upstream::defs::Value type. But what if that type is defined like this:

// This module is private!
mod defs {
    pub struct Value;
}

In this case, the downstream crate cannot name this type since it cannot access the contents of a private module. Actually, even this isn't sufficient by itself. The Value type must be impossible to name in any way. It isn't enough to say that the path the trait chose to use is inaccessible: if the top level of our crate contains e.g. pub use defs::Value, then downstream implementations can use the re-exported path instead. Edge cases inside our edge cases! And if we cannot write the function signature in a downstream crate, we cannot implement the trait — it is sealed!

In our example, say that Value appears to be public API that can be named and used in downstream crates. We still don't know if the Example trait is sealed or not.

Deeper down the rabbit hole we go.

Sealing traits via making them impossible to name

To implement a trait, one must first name the trait being implemented: impl name::of::MyTrait for MyType. If the trait's name is inaccessible, the trait can't be implemented — it's sealed.

The usual method here is defining the public trait inside a private module:

mod private {
    pub trait CannotBeNamed {}
}

Downstream crates cannot access the contents of the private module, so they cannot name the trait.

But be careful! If the trait is re-exported elsewhere, that re-exported name may be accessible and the trait will no longer be sealed:

mod private {
    pub trait ReexportOops {}
}

// The name `this_crate::ReexportOops` is public.
// The crate can access its own private module,
// and downstream users can access this public re-export.
//
// This trait is not sealed!
pub use private::ReexportOops;

As a reminder, the trait whose sealed status we're working to determine was defined as:

pub trait Example: Super {
    fn frobnicate(x: crate::defs::Value);
}

Say we've checked and it turns out its name is publicly accessible. We still don't know if it's sealed or not.

We must go deeper!

Sealing traits via sealed supertraits

Implementing the Example trait for a type requires that the type already implement the Super trait first.

pub trait Example: Super {
    fn frobnicate(x: crate::defs::Value);
}

What if the Super trait is sealed? In that case, no downstream type could add an implementation for it — so they can't satisfy the prerequisites for implementing Example either. In that case, Example would be sealed.

This definition is recursive, though! To figure out if Example is sealed, we must first figure out if Super is sealed. If Super has supertraits of its own, And isn't known to be sealed via a function signature. we might need to figure out if they are sealed too. I guess we should re-start back at the top of this essay, and check Super and its supertraits and their supertraits next? Oof, that'll be a lot of work.

... much tedious effort ensues ...

Say that we've finally discovered that Super is sealed. Great! Downstream code cannot add its own Super implementations, so it cannot implement Example either.

Success? Example is sealed, then? 😅

Hang on... What if the Super trait has a blanket implementation?

How many steps does this rabbit hole even have?!

Blanket implementations ruin everything

Imagine Super is a sealed trait with a blanket implementation:

impl<T> Super for T {
    // Implementation details go here.
}

Any downstream types automatically implement Super thanks to that blanket implementation! Even though implementing Example requires implementing the sealed Super trait, that condition is trivially satisfied. Downstream crates are then free to implement Example, so Example cannot be sealed.

But not all blanket implementations are made equal!

For example, with the following blanket implementation, Example would be sealed:

// `Example` is still sealed with this.
impl<T> Super for Vec<T> {
    // Implementation details here.
}

Whereas with this blanket implementation, Example would not be sealed.

// `Example` is *not* sealed with this.
impl<T: Clone> Super for T {
    // Implementation details here.
}

How about with this one?

impl<T> Super for &T {
    // Implementation details here.
}

The answer: not sealed.

This post is already long enough, so I'll spare you from all the remaining edge cases here. For example, the blanket impl loophole also works transitively. Making cargo-semver-checks get this exactly right required learning about Rust's trait coherence rules. And writing an ungodly amount of cursed Rust to use in test cases. Keeping it that way will require staying on top of possible future changes to that aspect of Rust. A lot of work went into making this happen!

Sorry, where were we before stumbling into the finer points of language design? Ah right, we were trying to check if our coworker's change to the Example trait was a major breaking change or not.

 pub trait Example: Super {
     fn frobnicate(x: crate::defs::Value);

+    // Enables new functionality in our app.
+    fn tweak() {
+        // some default implementation here
+        // which can optionally be overridden as needed
+    }
 }

Say we checked and Super doesn't have a blanket implementation. It's also sealed, so Example should be sealed too. Are we done yet?

Finally, yes! Our coworker's PR is not a major breaking change! 🎉

And it only us took, what, an hour of tedious labor and half a PhD in SemVer and language design to determine this... I don't know about you, but I can't wait to never do this by hand again!

Automation handles all this at superhuman speed

As of our v35.0 release, cargo-semver-checks handles all these cases correctly. It does so in fractions of a second. Automation wins the game of SemVer trivia, and the score isn't close.

SemVer is a cornerstone of Rust development, but its rules are complex and time-consuming to master and apply. We humans are bad at upholding SemVer on our own.

It is not desirable for the entire Rust community to invest precious skill points into a PhD in SemVer. Humanity has better uses for those person-millennia of effort. If you want that PhD in SemVer, though, we welcome new contributors — please reach out!

cargo-semver-checks is already very good at what it does. It will only get better from here! It's trusted by Rust's biggest projects, and is slated to become a built-in part of cargo. We'd love your support in making it even better! And if you aren't using it yet, we'd love to help you get started!

If you liked this essay, consider subscribing to my blog or following me on Mastodon, Bluesky, or Twitter/X. You can also fund my writing and work on cargo-semver-checks via GitHub Sponsors, for which I'd be most grateful ❤

Discuss on r/rust or lobste.rs.