Prevent ID-mistakes using Rust types

Recently, I've been interested in designing software to make hard-to-find bugs reveal themselves at compile-time. An idea I've read about (I forgot where) that I found fascinating is the use of a special type for IDs in objects that represent database rows.

Problem

For example, there may be a User table and a Posts table in the database, with each post belonging to one user. Each of these would be mapped to a class/struct in the programming language. Perhaps each of them has a numeric, auto-incrementing ID field, which is used for the foreign key in the database.

Now, if one were careless, it would be possible to write

post.user_id.id == post.id

instead of e.g.

post.user_id.id == user.id

Or one might write

user1.id < user2.id

instead of

user1.created_ts < user2.created_ts

which we don't want, because ids are an implementation detail.

Or one might reveal an ID to a user, while we have a policy of not doing that.

I'll admit these are not a very common problem. But I have encountered a bug related to it at least once. And applying this to all types adds up to a lot of bugs caught very early.

Solution

So let's have a look at how to do this. Sorry if this is not idiomatic Rust, it's not my primary language (yet). We set up the ids, using a phantom generic argument to specify the id type:

#[derive(Debug)]
struct Id<T> {
        val: i32,
        _phantom: PhantomData<T>,
}
impl<T> Id<T> {
        pub fn new(id: i32) -> Id<T> {
                return Id::<T> {
                        val: id,
                        _phantom: PhantomData {},
                };
        }
}
impl<T> PartialEq for Id<T> {
        fn eq(&self, other: &Id<T>) -> bool {
                return self.val == other.val;
        }
}

Then we use these ids in our user and post structs, using the entity of the target as the generic argument:

#[derive(Debug)]
struct User {
        id: Id<User>,
}

#[derive(Debug)]
struct Post {
        id: Id<Post>,
        user_id: Id<User>,
}

Then in the main function, we can create some users and compare ids as usual:

let id1 = Id::<User>::new(12);
let id2 = Id::<User>::new(12);
let id3 = Id::<User>::new(55);
println!("{:?} == {:?} => {:?}", id1, id2, id1 == id2); // true
println!("{:?} == {:?} => {:?}", id1, id2, id1 == id3); // false

But let's see what happens if we accidentally compare incompatible ids:

let id4 = Id::<Post>::new(12);
println!("{:?} == {:?} => {:?}", id1, id2, id1 == id4);

instead of incorrect runtime behaviour, we get a compile-time error:

error[E0308]: mismatched types
|     println!("{:?} == {:?} => {:?}", id1, id2, id1 == id4);
|                                                       ^^^ expected struct `User`, found struct `Post`

Admittedly the error is not super clear if you make this mistake, but it points you in the right direction.

Let's look at the database structs:

let user = User {
    id: Id::<User>::new(12),
};
let post = Post {
    id: Id::<Post>::new(12),
    user_id: Id::<User>::new(12),
};
println!("{:?}", user.id == post.user_id); // correct, works
println!("{:?}", post.id == post.user_id); // incorrect, fails

These special types also solve the comparison problem, since they only implement Eq but not Ord:

let user2 = User {
    id: Id::<User>::new(12),
};
println!("{:?}", user.id == user2.id);

It cannot suggest the correct solution (in fact it suggests adding Ord), but it does force you not to compare ids.

Finally, we don't implement Display, so the objects cannot easily be printed to the screen by mistake. For debugging, the :? formatter is available, but this includes extra data to make it unusable in UIs. The debug display can be improved using

trait Table {
        fn table_name() -> String;
}
impl<T: Table> Debug for Id<T> {
        fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
                write!(f, "{0:}Id:{1:}", T::table_name(), self.val)
        }
}

and some extra code, after which we can do

    println!("{:?}", id1);
    // UserId:12
println!("{}", id1);
// error

Summary

We created a special type for ids, which differentiated between different tables by using generics. This caught several problems at compile-time, which would have been runtime bugs (not errors) had the ids been standard integers.

Comments

No comments yet

You need to be logged in to comment.