Wilson's Personal Website

Fun with Types: Units of Measure

Units of measure are a way to tell 2 things of the same type apart in code.

Or another way, you can't subtract 32B0F from 6' it just doesn't make sense. You see those 2 numbers and you know you can't do that. So let's make the code know that.

In TypeScript?!

Yeah, I know F# and other languages in that family have actual support for Units of Measure, but I'm mostly in TypeScript land and needed them.

And TypeScript doesn't really support it.

Ok Convince Me...

Let's devise a horrible example:

interface IItem {
  id: string;
  name: string;
  description: string;
}

interface ICharacter {
  id: string;
  name: string;
  description: string;
  items: string[]; // Item IDs
}

Now the name and description of each of those are the same type. You would use them the same way, might compare them, and might add them together in some way.

But those id values? Nope. Those are really Item id and Character id. You can't add a character id to a the item list on a character but the code will let you. This is what we want to deal with.

Right now this works:

const item = { id: "xyz" } as IItem;
const character = { id: "xyz" } as ICharacter;

const areEqual = item.id === character.id; // is true

That does suck, but what can we do?

Units of Measure can help solve this problem.

// Our base Unit of Measure Type
//  Not having the extends string on the U parameter is possible
//  But nullable types don't work because all nullable types can be null.
type UOM<T, U extends string> = T & { __UOM: U };
// We want to be able to have string IDs that have a unit of measure
type ID<U extends string> = UOM<string, U>;

interface IItem {
  id: ID<"Item">;
  name: string;
  description: string;
}

interface ICharacter {
  id: ID<"Character">;
  name: string;
  description: string;
  items: ID<"Item">[];
}

Now that we have this set up, magic happens:

describe("Given 2 items with different types on their ID", () => {
  const item = { id: "xyz" } as IItem;
  const character: ICharacter = {
    id: "xyz" as ID<"Character">,
    items: [],
    name: "",
    description: "",
  };

  it("when we compare their ids, it turns out false... I mean true.", () => {
    expect(item.id === character.id).toBeTruthy(); // TypeScript Error!
  });
  it("when I try and add a character to an item id list, it fails", () => {
    character.items.push("duh"); // This won't compile.
    character.items.push(charcater.id); // Nor will this.
    character.items.push(item.id); // this works fine!
  });
});

Why This works

It's faking out the type system. The & { __UOM:U} is the magical part. When we use as we tell TypeScript that this object really is this type. And since there is enough overlap between string and string & {__UOM:'Character'} TypeScript allows it.

However, that & {__UOM:'Character'} prevents it from matching & {__UOM:'Item'} as they can't match. While we didn't define the _UOM property, it's part of the type, so TypeScript will enforce it.

The tests ran fine

Yeah the jest compiler doesn't seem to care. But if you clone/open my repository in VSCode, you will see that there are TS errors /src/examples/uom.test.ts that a normal build will catch.

Isn't that a lot of Strings that will bulk up your code

TypeScript type erasure will actually remove all type information (in this case, anything outside of the string of the ID) during compilation. The tests above look like this when compiled.

describe("Given 2 items with different types on their ID", () => {
  const item = { id: "xyz" };
  const character = { id: "xyz", items: [], name: "", description: "" };

  it("when we compare their ids, it turns out false... I mean true.", () => {
    expect(item.id === character.id).toBeTruthy();
  });
  it("when I try and add a character to an item id list, it fails", () => {
    character.items.push("duh");
    character.items.push(charcater.id);
    character.items.push(item.id);
  });
});

Type Erasure is also why jest will compile the tests just fine and run them.

Moving on...

The power of this picks up as you use it. Let's extend the character some

interface IRPGCharacter {
  id: ID<"RPGCharacter">;
  attributes: {
    strength: UOM<number, "strength">;
    dexterity: UOM<number, "dexterity">;
  };
}

describe("Given a character with strength and dexterity", () => {
  const character = {
    id: "xyz",
    attributes: { strength: 10, dexterity: 10 },
  } as IRPGCharacter;
  it("when we compare them, then it fails like the ids", () => {
    expect(
      character.attributes.strength === character.attributes.dexterity
    ).toBeTruthy();
  });
});

Again type errors start yelling at you.

const getMaxLift = (x: UOM<number, "strength">) => {
  return x * 1000;
};

describe("Given a character with strength", () => {
  const character = {
    id: "xyz",
    attributes: { strength: 10, dexterity: 10 },
  } as IRPGCharacter;
  it("when we get their max lift, then we cant use dexterity", () => {
    const maxLift = getMaxLift(character.attributes.dexterity);
  });
});

More yelling at you

Wait a second, you just used strength as a number

Yes, yes I did. As soon as you go to use a unit of measure as only it's base type, it's allowed. This is because units of measure are to stop 2 different units from being adjusted with each other, but can be adjusted by a neutral unit.

The usual example of units of measure is:

const time = 5 as UOM<number, "seconds">;
const distance = 10 as UOM<number, "meters">;

You could multiply time by 5, which means it takes 5 times as long. So units of measure stop types from colliding with each other, but the base type is can still intermix with. You could make it safer by having UOM on those multipliers also, but it's not as important. Unless you have a bug in that area. Adding the units you expect something to be when you have a bug will help make sure the types you expect are what you are dealing with.

Ok, I can think of a good use for this

Absolutely, so much of the time, we are dealing with complex types that end up as either number of string. This lets you add a bit of meat to those types. And with type erasure, it comes with very little cost. I've found it useful for dealing with bugs with different IDs (hence using that as an example) because they are all strings, and with this I can make them different under the TypeScript hood. Cause I mean item.id starts looking the same the more you use it.

Read More

This code is based on how F# does units of measure.

An alternative, that is especially useful if you are doing more math, are the Unit capabilities of math.js.