Why Don't Kilograms Exist in TypeScript?


I’ve often wanted to represent custom data types within TypeScript such as kilograms, postcodes and email addresses. If possible, this would prevent devs from accidentally passing a user’s age into a function to sum two weights and from passing an email address into a function that fetches the lat/lng position of a postcode. Such things don’t logically make sense, but they’re possible in TypeScript if we only use the standard types available to us.

function sum(a: number, b: number) {
  return a + b;
}

const weightKg = 85;
const age = 22;

const meaninglessResult = sum(weightKg, age); // this will pass type checking

Even if we clearly label our variables, mistakes can happen.

How can we overcome this limitation? We have two main choices:

  1. Value objects (classes which represent a custom type, available at runtime)
  2. Branded types (unique custom types, available at compile time)

Value objects

These are classes which you use instead of primitive types. For example:

const appleWeight = Weight.create(25, 'g')
const bagWeight = Weight.create(2.1, 'kg');
const personAge = Age.create(25);

function sumWeights(weightA: Weight, weightB: Weight) {
    return weightA.add(weightB);
}

sumWeights(appleWeight, bagWeight);
sumWeights(appleWeight, personAge); // type error

Now our weights are type-safe! Notice that we can sum different kinds of weights (both grams and kilograms in this example) in addition to blocking non-weight numbers, like age.

What does the weight class look like? Take a look:

// Weight.ts
export class Weight {
  private readonly weightKg: number;

  private constructor(weightKg: number) {
    if (weightKg <= 0) {
    // Perform input validation
      throw new Error("Weight must be a positive number.");
    }
    this.weightKg = weightKg;
  }

  create(value: number, unit: 'kg' | 'g'): Weight {
    switch (unit) {
        case 'kg':
            return new Weight(value);
        case 'g':
            return new Weight(value/1000);
        default:
            throw new Error('Invalid unit');
    }
  }

  toGrams(): number {
    return this.weightKg * 1000;
  }

  toKilograms(): number {
    return this.weightKg;
  }

  equals(other: Weight): boolean {
    return this.weightKg === other.weightKg;
  }

  add(other: Weight): Weight {
    return this.weightKg + other.weightKg;
  }
}

Branded types

These are lighter-weight alternative to value objects. Branded types allow you to create custom types based on primitives that only exist at compile time.

type WeightKg = number & { readonly __brand: unique symbol };

function kg(value: number): WeightKg {
  if (value <= 0) {
    throw new Error("Weight must be a positive number");
  }
  return value as WeightKg;
}

Example usage:

function sumWeightKg(weightA: WeightKg, weightB: WeightKg): WeightKg {
  return weightA + weightB;
}

const appleWeight: WeightKg = kg(0.2);
const bananaWeight: WeightKg = kg(0.25);

const totalWeight = sumWeightKg(appleWeight, bananaWeight);

While lighter weight, it would be slightly more verbose to sum kilograms and grams with branded types compared to value objects. You’d need to convert the grams to kilos before passing it into the function:

const totalWeight = sumWeightKg(gToK(appleWeight), bananaWeight);

You also can’t check types at runtime, unlike with value objects, which can come in handy now and again.

Have you faced this type issue with Typescript, and what’s your preferred method of overcoming it?