I'm working at a company with a very large Rust codebase with thousands of endpoints. We use multiple repos and each service publishes its API as crates. A few years ago those were manually written and teams used exhaustive enums frequently in their API. It did not prevent runtime issues as older clients could receive responses with new variants from newer servers. It also made versioning painful because semver would dictate a major bump, but Rust's nominal typing means that types from different major versions get different identities, making it very inconvenient to integrate with these types (think shared helper functions, extension traits, ...). This caused people to release new variants in feature bumps and wreck havoc in the dependency tree by breaking compilation of intermediate libs.
Since then we switched to code generation from an IDL for the API model. I was leading this project and decided to produce non_exhaustive enums for the Rust code: it was 2 years ago and the situation drastically improved.
We have a CI step running lints with `non_exhaustive_omitted_patterns` mentionned in an other comment. That's the right approach for such a public API. The big difference is that is that warnings are local to the crate you're working on, so they're more actionable than errors.
This was all in the context of modeling the API of a distributed system. I still tend to use non_exhaustive more often now even for regular helper libraries. It always requires some thought as you need to plan for the forward compat of your code.
If you're building one binary you can choose when to update and fix all the referents at the same time. A distributed system does not have that luxury and it makes more sense to require forward compatibility.
I guess another option is a two-phase update where you first update all the services to accept the new enum variant without ever emitting it, then you can phase in services that use it. The downside with this strategy is that you're locked into upgrades and you can't roll back individual service versions, so to be safe you probably need to delay use of the new enum variant by 2-3 releases (at some point rolling back is not a viable option and you have to publish fixes in a new release anyway).
So in a service world the only options are either "require forward compatibility" or "delay rollout of a new feature to 3 release cycles after all services in distributed system are updated" which is like a month minimum. I can see why forward compatibility is the default, and also that you might want to keep the two-phase deployment strategy for certain cases.
I just fundamentally disagree with Google on how to evolve data structures. Sloppy open-domain protobuf field presence, enum values, whatever are illusory extension points. Better to have discrete versions of a data structure --- Foo1, Foo2, Foo3, etc., and rigid checking in each one than to have just one "Foo" with optional-everything that changes implicitly over time.
Honestly what I tend to see is that most enums are not exposed publicly at all besides maybe error types and configuration options (where the user usually constructs but does not match on them). In both cases #[non_exhaustive] is valid. For all of the non-public enums you get to enjoy the compiler exhaustiveness enforcement.
With #[non_exhaustive], adding a variant is not a breaking change, but only if that variant can safely be ignored. If it can't be, then it's still a breaking change, BUT you don't get a compiler error. That's a very big drawback IMO.
This makes me think of how the PNG format handles this. For context, PNG files consist of a list of chunks of different types (image data, color profile, text comment, etc). Each chunk has some flag bits that tell you whether it can be safely ignored when decoding the image, safely retained when the image data is changed, etc. That way the decoder can ignore unknown ignorable chunks and error on unknown non-ignorable ones.
It's always up to callers to decide to handle everything, or decide that additions are non-fatal and use a wildcard branch to ignore them. The same enum could need to be exhaustive for one use case, or subset matched by another.
edit: I don't know rust. in other languages he's such catch all patterns exist and you have to either capture each possible value explicitly or you express "here in this fu, I take care of these and all others I do that..."
It’s the eternal struggle between flexibility and strictness that doesn’t really have a silver bullet answer. Or maybe it’s better framed as “flexibility, safety, backwards compatibility, pick any two”?
Something I've been wanting in Typescript (and I guess it's the same thing here in Rust) is to type-check exhaustiveness of the known enum values, but also enforce that unknown values are handled. I've made utils for that but they're a bit awkward to use compared to native constructs like switch/case.
At Google, the bottleneck is that a team changing a commonly used enum will not know how to update all the broken use sites, since it can be affecting dozens or hundreds of teams.
It is better to decouple the enum change from the use site change and let consuming teams update on their own schedule.
Note that teams generally manage their own deployments, so even a repo wide code change doesn’t force the update of an older deployed binary.
There are reasons why Google pays this price! But I think it's one of the worst practices for much smaller organizations to adopt.
#[non_exhaustive]
enum Foo {
Bar,
Baz
}
fn main() {
let foo = Foo::Bar;
match foo {
Foo::Bar => (),
Foo::Baz => ()
// no `_` arm necessary here
}
}
The non_exhaustive attribute only applies to users outside of the compilation unit, because it's a tool for expressing a promise about the external API.If you really don’t care about compiler warnings, what’s the actual point of using Rust?
Second case is the same. The other team needs to definitely know, that their assumptions expressed in their current code are broken and need to be adjusted.
We just need to stop to take whatever shit Google is doing as the one bar to measure all others, and need to understand that they may be different requirements than a typical business. We are not Google.
I don't want to update every place someone matches on my enum, when it's something not even in my project, just to get my PR to merge. Three teams use it? Fine. Ten? More? Not fine. You are adding cases into someone else's code just to unblock your own work.
The second case is actually not the same at all. Realistically, they will just pin your dependency to an older version. Now they can't benefit from new features and security fixes without going in and adding in a new case. Massive waste of time for them.
And of course I'm not just taking whatever Google says at face value. I read their justification, and it makes total sense for anyone that works on a non-trivial project. It follows how any stable public API is supposed to be designed.
Then don't upgrade your dependency onto the other crate.
Anything more than a hobbyist will have properly pinned versions, and not just sling around version ranges loosely. At the very least will not update lock file just like that, without considering, that there may be consequences.