Nominal types in TypeScript

Improving our type-safety with fine-grained types

March 8th 20225 minutes read

Consider the following case:

type PostId = string;
type UserId = string;

function publishPost(userId: UserId, postId: PostId) {
  //
}

const postId: PostId = 'post-xyz-123';
const userId: UserId = 'user-xyz-123';

publishPost(userId, postId); // correct
publishPost(postId, userId); // would love to error, but passes

Playground Link

We would believe that TypeScript would prevent us pass a value of type PostId when UserId is expected.

Unfortunately, both userId & postId can be used interchangeably. For the compiler, the names are irrelevant, as they are an alias to the same string type.

TypeScript doesn't care how we name our values, only if the shape they describe (e.g string) can satisfy the constraints.

Structural typing

If you want a thorough explanation, feel free to read the entry from the official docs and skip this section.

TypeScript uses structural typing. Which is a bit different from what you might have used in Java.

In our case TypeScript doesn't care if a type has an explicit inheritance from another. If its contents are equal (or a superset) it's fine. Here's another example:

type Student = {name: string};
type Teacher = {name: string};

const student: Student = {name: 'Benjamin'};
const teacher: Teacher = student; // no issue

For TypeScript both Student & Teacher are equivalent. As long as the contents are the same, any implementation that uses a Studentvalue, can be also satisfied with a Teacher one. In languages like Java, this is a no-go.

class Student {
 public String name;
}
class Teacher {
 public String name;
}

// error: incompatible types: Teacher cannot be converted to Student
Student student = new Teacher();

Of course, in our TypeScript snippet, if we add another property to the Teacher, we will be notified of it.

// Property 'classes' is missing in type 'Student' but required in type 'Teacher'.(2741)
type Teacher = {name: string; classes: Array<string>};

We might feel a false sense of security when using TypeScript, expecting the compiler to save us. But, in reality, we might be introducing similar bugs in our code if we're not careful.

How can we fix this?

We need a way to differentiate between the two types.

First, we create a symbol that we can use to identify our nominal types. It's worth noting that it won't exist after compilation.

declare const __nominal__type: unique symbol;

Then we enrich our base type (e.g. string) with a symbol that we can use to identify our nominal types. Two types are considered equivalent if they have the same symbol, in addition to their contents.

export type Nominal<Type, Identifier> = Type & {
  readonly [__nominal__type]: Identifier;
};

Putting it all together

// Nominal.ts
declare const __nominal__type: unique symbol;
export type Nominal<Type, Identifier> = Type & {
  readonly [__nominal__type]: Identifier;
};

Some examples, where we might need a distinction:

type UserId = Nominal<string, 'UserId'>;
type PostId = Nominal<string, 'PostId'>;
type OrgId = Nominal<string, 'OrgId'>;
type ProjectId = Nominal<string, 'ProjectId'>;

type CustomerId = Nominal<string, 'CustomerId'>;
type ClientId = Nominal<string, 'ClientId'>;

type projectInvitationToken = Nominal<string, 'projectInvitationToken'>;
type passwordResetToken = Nominal<string, 'passwordResetToken'>;

type EUR = Nominal<number, 'EUR'>;
type USD = Nominal<number, 'USD'>;

type Miles = Nominal<number, 'Miles'>;
type Kilometers = Nominal<number, 'Kilometers'>;

There you have it

type UserId = Nominal<string, 'UserId'>;
type PostId = Nominal<string, 'PostId'>;

let userId = 'xyz' as UserId;
let postId = 'xyz' as PostId;

/*
Type 'PostId' is not assignable to type 'UserId'.
 Type 'PostId' is not assignable to type '{ readonly [__nominal__type]: "UserId"; }'.
 Types of property '[__nominal__type]' are incompatible.
 Type '"PostId"' is not assignable to type '"UserId"'.
*/
userId = postId;

The elephant in the room

Yes, we have to use as. We can't assign the xyz string directly.

// fails
/*
Type 'string' is not assignable to type 'UserId'.
 Type 'string' is not assignable to type '{ readonly [__nominal__type]: "UserId"; }'.
*/
let userId: UserId = 'xyz';
/*
Type 'string' is not assignable to type 'PostId'.
 Type 'string' is not assignable to type '{ readonly [__nominal__type]: "PostId"; }'.
*/
let postId: PostId = 'xyz';

// works
let userId = 'xyz' as UserId;
let postId = 'xyz' as PostId;

You might consider it cheating, but TypeScript has sharp knives in its feature set. It's up to us to know when to use them.

That said, we can improve it. Let's concentrate the as type-casting in one place.

function UserId(id: string): UserId {
  // validation can go here
  return id as UserId;
}

function PostId(id: string): PostId {
  // validation can go here
  return id as PostId;
}

let userId = UserId('id');
let postId = PostId('id');

Playground Link

It will also allow us to take it a step further and introduce validation, using a library like Zod.

Other references