Combining Rust's traits and enums
2026-02-09 technologyIt is not a new invention, being mentioned on stack overflow in 2020, but apart from that it is a pattern I haven’t seen mentioned often and this blog aims to change that. This pattern appeared so often in the Asfaload codebase and I find it such an elegant solution that I wanted to blog about it!
Applicability
As with Asfaload, we want to support a large range of software forges we defined traits to be implemented for each forge like GitHub or GitLab. Here’s a simplified version:
pub trait ForgeTrait
where
Self: Sized,
{
fn new(url: &url::Url) -> Result<Self, ForgeUrlError>;
fn project_id(&self) -> String;
}
With this trait defined, we go to implement it for each forge, for example here for GitHub:
impl ForgeTrait for GitHubRepoInfo {
fn new(url: &url::Url) -> Result<GitHubRepoInfo, ForgeUrlError> {
let host = url.host_str().unwrap_or("");
if !GITHUB_HOSTS.contains(&host) {
return Err(ForgeUrlError::InvalidFormat(format!(
"URL must be one of {}",
GITHUB_HOSTS.join(","),
)));
}
// Impl continues
// ....
}
}
But as we see in these code excerpts, each implementation will only handle one
type of URL. For example the GitHubRepoInfo will not be able to handle a
GitLab URL.
It means that before calling the GitHub repo info, we need to analyze the URL
we received. We could define a build function that analyses the URL that is
passed and builds the corresponding instance.
Note, however, that the different implementations of the traits are of
different type, which means you end up returning a Box and use dynamic dispatch
with a function signature of this kind: fn builder(url: &url::Url) -> Box<dyn ForgeTrait>.
This introduces a level of indirection via vtable lookups, which is much slower, in case that matters to you.
The other solution to return data of different types to its caller, is to wrap this data in an enum,
which in our case we name ForgeRepos.
You end up with a function that has a simpler signature as it returns data of a well defined type:
fn builder(url: &url::Url) -> ForgeRepos. This is the solution we will implement.
Defining the enum
As explained earlier, our enum will wrap the individual forges’ data, so as to define a unique type. For example to support github and gitlab, we can define
enum ForgeRepos {
Github(GithubRepoInfo),
Gitlab(GitlabRepoInfo),
}
and we can define an initialiser of ForgeRepos instances:
impl ForgeRepos {
pub fn new(url: &url::Url) -> Result<Self, ForgeUrlError> {
// First extract host
....
// Then match on it
match host {
"github.com" => {
// build repo info to be returned from github url
....
// Then return it wrapped
Ok(Self::Github(repo_info))
},
"gitlab.com" => {
// build repo info to be returned from gitlab url
....
// Then return it wrapped
Ok(Self::Gitlab(repo_info))
},
other => Err(ForgeUrlError::Unsupported(other))
}
}
}
Improving further
This works fine, but the problem is that at each use, you need to match the ForgeRepos value you have:
match forge_repo_instance {
ForgeRepos::Github(info) => { // work on info
},
ForgeRepos::Gitlab(info) => { // work on info
},
}
The solution here is to implement the trait for the wrapping type itself! You still need to do the match, but once per function defined in the trait.
We already named the initialising function new, so for our example we just need to implement the project_id function:
impl ForgeTrait for ForgeRepos {
fn new(url: &url::Url) -> Result<Self, ForgeUrlError> {
// As defined above
// ....
}
fn project_id(&self) -> String {
match self {
Self::Github(info) => info.project_id(),
Self::Gitlab(info) => info.project_id(),
}
}
}
From then on, you work exclusively with the wrapping enum ForgeRepos in your code.
The moment you want to add a new forge, for example ForgeJo, we just need to
-
define the
ForgeJoRepoInfostruct which holds the data of a ForgeJo repo -
implement the trait
ForgeTraitfor that struct -
Add the
ForgeJocase to theForgeReposenum -
modify the
ForgeRepos::newfunction so that it handles the new url scheme and in that case returns aForgeJo(ForgeJoRepoInfo) -
add a branch to the matches in every functions of the
ForgeReposimplementation of the traitForgeTrait. The compiler will enforce exhaustive matching, so you can’t forget to handle the new variant.
Only the last two steps are additionally needed for this approach, but as the compiler enforces exhaustive matches, it will guide you (or your LLM). All the rest of your application is ready to handle the new forge!