Strongly typed IDs
I love strong types. They solve a frustrating group of issues where a variable's type is too general, potentially causing very subtle bugs.
In simple terms, strong types prevent accidental mixing of types. If I just told you a number like "5.5", you would have no idea what that number actually represents (is it kilograms? meters? something else?). What strong typing gives is akin to me saying "5.5 meters" instead.
It is generally easy and very cheap to implement in pretty much any language with typing, and the value it brings is immediate.
I'll mostly use TypeScript in the examples below, but the principles are true in most strongly typed languages. At the end is a list of implementations and "further reading".
Motivating example
Let's say you stumble upon the following function:
function findItemById(itemId: number) {
// ...
}
findItemById takes one number parameter. Any number can be passed in here, regardless of whether it is actually an item ID or not. It is therefore very easy to accidentally write something like this:
const itemId: number = model.itemIndex; // An index is not an ID!
const item = findItemById(itemId);
// Or this:
const itemId: number = model.categoryId; // This is not the right type of ID!
const item = findItemById(itemId);
The compiler would not give any warning or error. The code is correct, after all!
Although this is a very synthetic example, real code might not be as straightforward.
The solution
What we really want is a way to separate types, that have the same underlying primitive, from each other. So that we can write functions like this instead:
function findItemById(id: ItemId) {
// ...
}
// Now, the code would raise a compile-time error:
const itemId: number = model.itemIndex;
const item = findItemById(itemId);
// Causes: error TS2345: Argument of type 'number' is not assignable to parameter of type 'ItemId'.
Suddenly the compiler is your friend, and tells you that something is wrong.
The solution is to force the type to be of a specific branded type:
interface BrandedType<BrandT> {
type: BrandT;
// Replace the above line with this for a more lenient version that avoids
// strict "as" coercion.
// type?: BrandT;
}
type IdType<T, BrandT> = T & BrandedType<BrandT>;
type ItemId = IdType<number, "ItemId">;
// Will cause an error like this: Type 'number' is not assignable to type 'ItemId'
const itemId: ItemId = model.itemIndex;
Note that it is still possible to coerce it to be the right type, and with branding this is necessary:
const itemId: ItemId = 5 as ItemId;
For the more lenient version which avoid this coercion, see the Stack Overflow question.
Once a type has been coerced into the right type it will typically stay that way forever, so this significantly reduces the possible locations where the bug arises.
The most typical bug that happens, in my experience, is trying to use the wrong ID type in the wrong location. As the compiler now raises an error, the risk of that bug disappears.
const itemId: CategoryId = model.categoryId;
findItemById(itemId); // Error: Argument of type 'CategoryId' is not assignable to parameter of type 'ItemId'.
Different languages have different ways to implement strong types. This is not really a tutorial about how to implement it (see instead the various implementations below), but a very strong encouragement to use it!
In practice
The very excellent Box2D library (a C library), uses strong typing of IDs. It has functions like this (from the "Body" documentation):
b2Body_SetGravityScale (b2BodyId bodyId, float gravityScale);
Although the gravityScale parameter is weakly typed, it is impossible to accidentally swap the parameters.
// This won't compile
b2Body_SetGravityScale(2.0, bodyId);
But imagine the function was defined like this:
b2Body_SetGravityScale (int bodyId, float gravityScale);
// This would then compile, possibly with a warning about casting float to int:
b2Body_SetGravityScale(2.0, bodyId);
Again, because Box2D uses strong typing, a whole host of issues is removed, and the function itself quite clearly documents what it does.
Personal thoughts
In addition to reducing bugs, I find that using strong types makes code much easier to read and reason about.
Code is, after all, documentation. It tells us exactly what a piece of software will do. The clearer the code is, the easier it is to understand and the easier it is to modify. This is true whether it is humans or coding assistants that are working on a codebase. Both need a good foundation to be able to comprehend the logic.
By adding strong types, one can more strictly document what a parameter intends to do, in a way that is enforced by the compiler. This is much better than having just the parameter name documenting the purpose. Anything that isn't strictly enforced by the compiler will quickly drift as the code changes. This is why code comments often are misleading, they don't actually couple to the logic in any way!
Over my career I have moved more and more in the direction of stricter conventions. The more the compiler can help enforcing correct code, the easier it becomes to develop software. This is similar as to why I am increasingly averse to weakly typed languages, and why I virtually never generate code with assistants (they tend to reduce code clarity).
The codebase I use at work, for example, has been converted from JavaScript to TypeScript for this very reason. Before it was converted, it was quite difficult to know what all the members of a type were, or what parameters a function really took. But after the conversion, everything has become so much easier to work with.
Libraries / Implementations
See the Wikipedia page for an overview, including demonstrations in several languages.
List of implementations/libraries I've used:
- C#
- I've been using this library (StronglyTypedId)
- C++
- There are some good pointers in this Stack Overflow question
- In a game I was working on, adding a new ID type was as straight-forward as:
using unit_id = id_type<struct unit_id_tag>; - TypeScript