Bridging the gap between type alias and NewType

Setting aside recent discussions hapenning in IRLO, is there a way to bridge the gap between type aliases and NewTypes?

I recently stumbled with a refactoring involving a NewType that made me wish there was a more transparent (read Zero cost abstraction) way to handle things.

In my code base I have the User struct with a UserId field which is just a wrapper around ULID.

pub struct UserId(Ulid);

pub struct User {
 id: UserId,
  ... // bunch of fields here
}

Since throughout the codebase I often handle more than one User or UserId at a time, I decided to introduce NewTypes for both:

pub struct AuthenticatedUserId(Ulid);

pub struct AuthenticatedUser {
 id: AuthenticatedUserId,
  ... // bunch of fields here
}

The pain during the refactoring came whenever I used references to the former types (&User and &UserId). Let's take a function as an example:

fn some_fn(user: &User)

Whenever I was using a single instance of User, now I had to clone it because the From trait consumes the type being converted, even if the destination type is nothing but an alias:

{
  let some_user = User { ... }
  some_fn(&some_user).await?;
  // I can keep doing things with some_user
}

Then turned into:

fn some_fn(user: &AuthenticatedUser)

{
  let some_user = User { ... }
  some_fn(&some_user.clone().into()).await?;
  // I can't keep doing things with the original instance of some_user unless I clone it
}

I know there have been prior discussions around this, just wanted to share my experience going through it and perhaps see if things have changed in the last few years in Rust to make this more ergonomic.

Unless Rust gets non-lifetime-based subtyping, there will always be some significant gap or facade building when going from one type to many types.

I can think of a lot of approaches and can give some examples if you want, but I'm unsure if they would be a "yeah I know" or not so I've skipped them for now.

  • Make a trait both types satisfy and have some_fn rely on the trait
  • Make the struct generic over authenicated or normal and have functions be generic over that
  • Have the bidirectional From or equivalent in safe code and lean on optimization
  • Make the structs' layouts the same and have pointer-casting/transmuting AsRef/From
  • Use a view type common to the structs
  • Give up on type-based invariants and have an authenticated field

Safe transmute and/or better custom DST support could ease some of these approaches, but we still don't have them. Maybe there's something in crate form.


By the way, was your example supposed to be:

fn some_fn(user: &User)
{
    let some_user = AuthenticatedUser { ... }
    some_fn(&some_user.clone().into()).await?
}

...because otherwise it seems like you're not enforcing stricter type-based invariants with AuthenticatedUser anyway,[1] so why have distinct types?


  1. there's a From<User> for AuthenticatedUser apparently ↩ī¸Ž

1 Like

Good observation :laughing: , but no. I know that having a From<User> for AuthenticatedUser implementation is a footgun, but it's used only in special cases. The snippet above is from an integration test but, of course, there are outliers as usual (i.e. I have inter-service communication that, because the data-flow doesn't go through the API layer, doesn't have access to an instance of AuthenticatedUser).

This was just the first iteration of the refactoring. In hindsight, since most of the pain came from places where I had to convert a AuthenticatedUser into a &User, this was enough of a hint (cough AsRef cough) to guide me to the final version of the newtypes.

So I went from this:

pub struct AuthenticatedUserId(Ulid);

impl AsRef<Ulid> for AuthenticatedUserId {
    fn as_ref(&self) -> &Ulid {
        &self.0
    }
}

impl From<UserId> for AuthenticatedUserId {
    fn from(value: UserId) -> AuthenticatedUserId {
        Self(value)
    }
}

pub struct AuthenticatedUser {
 id: AuthenticatedUserId,
  ... // bunch of fields here
}

impl From<User> for AuthenticatedUser {
    fn from(user: User) -> Self {
        // Long destructuring here
    }
}

impl From<AuthenticatedUser> for User {
    fn from(authenticated_user: AuthenticatedUser) -> Self {
        // Long destructuring here
    }
}

To this:

pub struct AuthenticatedUserId(UserId);

impl AsRef<UserId> for AuthenticatedUserId {
    fn as_ref(&self) -> &UserId {
        &self.0
    }
}

impl From<UserId> for AuthenticatedUserId {
    fn from(value: UserId) -> AuthenticatedUserId {
        Self(value)
    }
}

pub struct AuthenticatedUser(User);

impl AuthenticatedUser {
    pub fn get_id(&self) -> AuthenticatedUserId {
        AuthenticatedUserId(self.0.id)
    }
}

impl From<User> for AuthenticatedUser {
    fn from(user: User) -> Self {
        Self(user)
    }
}

impl AsRef<User> for AuthenticatedUser {
    fn as_ref(&self) -> &User {
        &self.0
    }
}

In retrospect, the type names could have been a hint: whenever you find an is-a relation in the class inheritance sense of the word, in Rust the solution typically is composition instead where the subtype is composed of the supertype and possibly more.

Functionality like what's provided by AsRef, Deref etc can then be added on top if desired.

3 Likes

Yes, initially my plan was this, but somehow along the way I tripped and thought that my initial solution was not going to work because I needed also the guarantees from the AuthenticatedUserId, and in a brain-fart thought I decided that I needed a full copy of User in order to achieve that (so that I could access the id property).

So, basically a brainfart where I didn't consider the option of having a getter, and went with the property.

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.