Type safe identifiers in C++

In many cases when writing an API, we need to express the concept of an identifier. In operating systems, we need to identify resources, like a socket, an open file or a thread. Or when writing for instance a blogging system, we need to identify entities like a comment, a user or a post. In most systems these identifiers take the form of an integer.

In the family of C-like languages, usually we find two different forms of codifying this in an API. On is with by using integers directly. For instance:

void LikeComment(int user_id, int comment_id);

Another way is by creating typedefs that masquarade the identifiers us such:

typedef int UserId;
typedef int CommentId;
void LikeComment(UserId user, CommentId comment);

This second form is slightly more future-proof, in the sense that it can allow you to easily change the underlying integer type that it is used. If for instance at some point we discover that we need more than 2^31 comments, we can easily switch the underlying type to an int64_t.

On the other hand, arguably, typedefs obfuscate the underlying type. For instance one might want to be able to see that the CommentId is a primitive type and can efficiently be passed by value.

The most important drawback of both methods is that they provide no type safety at all. One could write:

// Notice the order of the arguments
LikeComment(comment_id, user);

And the compiler will hapilly do the wrong thing. You are also limited in your use of polymorphism The following code is illegal:

void Delete(UserId user);
void Delete(CommentId comment);

But it is perfectly legal to do crazy stuff like:

CommentId comment = user1 + user2;

In C++ there is a better way to solve all these problems. We can define a class that wraps an integer to act as an identifier. At the same time, we can use a template argument to differentiate between different identifiers and simply disallow numeric operations that do not make sense for identifiers.

template <typename T>
class TypeSafeIdentifier {
 public:
  explicit TypeSafeIdentifier(int id = 0) : id_(id) {}

  /// Get the identifier value as an int
  int value() const noexcept { return id_; }

  bool operator<(TypeSafeIdentifier<T> rhs) const noexcept;
  bool operator==(TypeSafeIdentifier<T> rhs) const noexcept;
  bool operator!=(TypeSafeIdentifier<T> rhs) const noexcept;
 private:
  int id_;
};

We have added an operator< that might seem out of place. But it is a usefull addition in order to allow us to put our identifiers in ordered containers like std::map. It is still possible to access the underlying integral value in order to eg. store it in a database.

To use this type we do the following:

class User;
using UserId = TypeSafeIdentifier<User>;

Notice that the User class does not even need to be defined. Just a forward declaration is sufficient. It is now impossible to mix-up UserIds with CommentIds by accident.

If we use the TypeSafeIdentifier to define the UserId and the CommentId it is now impossible to call the LikeComment function with the wrong order of arguments.

Lets see a more extensive example with cats and dogs :-)

#include <cassert>
#include <iostream>
#include <map>
#include <string>
#include "type_safe_identifier.h"

class Cat;
using CatId = TypeSafeIdentifier<Cat>;

class Dog;
using DogId = TypeSafeIdentifier<Dog>;

void Feed(DogId dog) { std::cout << "Feeding dog" << dog << std::endl; }

void Feed(CatId cat) { std::cout << "Feeding cat" << cat << std::endl; }

void DeclareParent(DogId parent, DogId child) {
  assert(child != parent);
  std::cout << "Dog " << parent << " is the parent of dog " << child
            << std::endl;
}

int main() {
  CatId minty(1);
  DogId lassy(1);
  DogId spot(2);

  // These will do the right thing
  Feed(minty);
  Feed(spot);
  Feed(lassy);

  DeclareParent(lassy, spot);

  // But this would cause an error because a cat cannot ever give birth to
  // a dog.
  // DeclareParent(minty, lassy);

  std::map<DogId, std::string> dog_names = {{lassy, "Lassy"}, {spot, "Spot"}};

  // As this will also cause a compilation error because you can't
  // look up a cat among a collection of dogs.
  // std::cout << dog_names[minty];

  for (auto it : dog_names) {
    std::cout << it.second << "'s DogId is " << it.first << std::endl;
  }
}

You can get the full type_safe_identifier header from github and use it on your own projects. It is a very simple and self-contained header file.

There's another example over there at github, claiming that you can't give bath to a cat. Well, that's obviously not true if you ask Maru.

Show Comments