Combining Rust's traits and enums

Raphaël 2026-02-09
In this post we will cover a pattern that appeared multiple times in our codebase: wrapping trait implementations in an enum. Although this is not the reason that I use this approach, for those who interested it avoids the performance cost of dynamic dispatch.

It 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 ForgeJoRepoInfo struct which holds the data of a ForgeJo repo
  • implement the trait ForgeTrait for that struct
  • Add the ForgeJo case to the ForgeRepos enum
  • modify the ForgeRepos::new function so that it handles the new url scheme and in that case returns a ForgeJo(ForgeJoRepoInfo)
  • add a branch to the matches in every functions of the ForgeRepos implementation of the trait ForgeTrait. 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!