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
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 Student
value, 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.
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');
It will also allow us to take it a step further and introduce validation, using a library like Zod.
Resources