TypeScript structural typing & branding

Within TypeScript a way to define an indentifier as a number is as follows:

const productId: number = 1;
const orderId: number = 1;

Here productId and orderId are both numbers. However there's a few things wrong with this:

  • Primitive types don't often mean anything to non-developers - it's easier to talk about Product Ids and Order Ids in terms of the domain and not strings, integers and decimals.
  • A ProductId and an OrderId is not a number. They may be represented by a number but are likely to be further constrained. For example these identifiers should be a positive number.
  • You can mix up passing in a ProductId where an OrderId is required as they are just numbers. Ideally the type system should protect you from this.

We generally want to avoid primitive obsession which means that simple values should not be modelled with primitive types such as numbers and strings. In TypeScript we can create two type aliases to create a new name to refer to the number type:

type ProductId = number;
type OrderId = number;

TypeScript Structural Type System

However because of the type system in TypeScript the above two types can be used interchangeably. TypeScript has a structural type system which is a way of relating types based on their members.

Here Thing1 and Thing2 are different classes but this code still compiles because they both have the same members.

class Thing1 {
  name: string;
}

class Thing2 {
  name: string;
}

let thing: Thing1;
//this works because thing1 and thing2 both have the same members
thing = new Thing2();

If we compare this to code written in C# this would not compile as it has a nominal type system meaning the types are not the same as they have different names.

public class Thing1
{
    public string Name { get; }
}

public class Thing2
{
    public string Name { get; }
}

Thing1 thing;
//this won't compile as Thing1 and Thing2 don't have the same name
thing = new Thing2();

Nominal Types in TypeScript

Withing TypeScript we can "brand" the type in order to replicate nominal typing. There are multiple ways of achieving this, however I'm currently using:

type ProductId = number & { readonly brand?: unique symbol };

const productId: ProductId = 1;

This creates an intersection type for ProductId, combining a number and a type with a unique symbol brand property.

We can now create a unique productId and orderId

type ProductId = number & { readonly brand?: unique symbol };
type OrderId = number & { readonly brand?: unique symbol };

const productId: ProductId = 1;
const orderId: OrderId = 1;

If we were to create a function with productId and orderId parameters but mixed up the order of the passed arguments we would get a complication error.

const doSomething = (productId: ProductId, orderId: OrderId): void => console.log(productId, orderId);

//this would not compile
doSomething(orderId, productId);

//this would compile
doSomething(productId, orderId);