Branded Types

🌱May 8, 2024.
evergreen 🌲
7 minutes read ⏱

Typescript is built on set theory, which is a cool feature for a language. it makes it possible to smash types together gracefully, like this:

type type User = {
email: string;
password: string;
}
User
= {
email: stringemail: string; password: stringpassword: string; }; type type AuthSession = {
sessionId: string;
}
AuthSession
= {
sessionId: stringsessionId: string; } type type AuthUser = User & AuthSessionAuthUser = type User = {
email: string;
password: string;
}
User
& type AuthSession = {
sessionId: string;
}
AuthSession
;

To make this work seamlessly, TypeScript uses structural typing. In structural typing, types where properties share the same name and type are compatible, even if the types have different names. For example:

type type LoginFormInfo = {
email: string;
password: string;
}
LoginFormInfo
= {
email: stringemail: string; password: stringpassword: string; }; type type User = {
email: string;
password: string;
}
User
= {
email: stringemail: string; password: stringpassword: string; }; const const formdata: LoginFormInfoformdata: type LoginFormInfo = {
email: string;
password: string;
}
LoginFormInfo
= {
email: stringemail: "me@domain.com", password: stringpassword: "Secret123" }; const const user: Useruser: type User = {
email: string;
password: string;
}
User
= const formdata: LoginFormInfoformdata
; // Compatible!

This is often very convenient!

The relaxed typing does make some issues easier to introduce, though. Consider this code, which has two names for structurally equal representations of a user:

type type UserLogin = {
email: string;
password: string;
}
UserLogin
= {
email: stringemail: string; password: stringpassword: string; } type type AuthenticatedUser = {
email: string;
password: string;
}
AuthenticatedUser
= {
email: stringemail: string; password: stringpassword: string; }

They share the same structure, but the names imply very different meanings.

function function authnUser(user: UserLogin): AuthenticatedUserauthnUser(user: UserLoginuser: type UserLogin = {
email: string;
password: string;
}
UserLogin
): type AuthenticatedUser = {
email: string;
password: string;
}
AuthenticatedUser
{
// "Securely" hash the password // and authenticate the user... return user: UserLoginuser; } // Only authenticated users can perform this action function function performSecureAction(user: AuthenticatedUser): voidperformSecureAction(user: AuthenticatedUseruser: type AuthenticatedUser = {
email: string;
password: string;
}
AuthenticatedUser
){
// ... } function function login(user: UserLogin): voidlogin(user: UserLoginuser: type UserLogin = {
email: string;
password: string;
}
UserLogin
) {
const const authenticatedUser: AuthenticatedUserauthenticatedUser = function authnUser(user: UserLogin): AuthenticatedUserauthnUser(user: UserLoginuser); function performSecureAction(user: AuthenticatedUser): voidperformSecureAction(user: UserLoginuser); }
yikes! I didn't pass an `AuthenticatedUser` but got no warning/error

This category of issue is possible because of structural type compatibility. AuthenticatedUser is a subset of MagicLinkForm, so the form submission data can be passed to a function that thinks it is getting an AuthenticatedUser. Yikes!

It would be helpful if the the type system could catch this bug.

There’s a more strict approach called nominal typing. Nominal types are only compatible if they share the same name. An AuthenticatedUser instance couldn’t be assigned a value of any other type, making this mistake impossible. Fantastic! But how can we do that in a structurally typed language like TypeScript? With branded types!

Branded types are a TS technique that emulates the behavior of a nominal type system by adding a unique field that no other type has. This makes the type structurally incompatible with every other type, which is exactly how nominal typing works. Check it out:

declare const const brand: typeof brandbrand: unique symbol;

the jumble of keywords is probably not the sort of thing you see in your day-to-day TypeScript work. Let’s break it down a bit:

  • declare is used to declare something exists in global scope. Importantly, it doesn’t require providing an implementation of that thing.
  • const brand says that we are creating an immutable variable named brand.
  • : unique symbol The colon after brand tells TypeScript that we are explicitly specifying the type of brand. unique symbol is the type being specified. It’s a special type that can only be assigned to const variables, so it’s important that we used const rather than let or var. Crucially, a unique symbol is guaranteed to be a unique type which can’t be assigned or compared to any other type.

“Can’t be assigned to any other type” is exactly what we need for nominal typing! The next step is figuring out how we can use our unique symbol. Symbols are valid keys for object properties, so brand can be used as property key and is guaranteed to be unique, so it won’t even show up as a member in intellisense. Check this out:

declare const const brand: typeof brandbrand: unique symbol;
type type Email = {
email: string;
}
Email
= {email: stringemail: string};
type type BrandedEmail = Email & {
[brand]: "email";
}
BrandedEmail
= type Email = {
email: string;
}
Email
& {[const brand: typeof brandbrand]: "email"};

In this example, I’m creating a type union with two parts:

  1. The data type I actually want
  2. Another type that uses the guaranteed unique value of [brand] as a key that can only be set to the value “email”

Colloquially, this is a “branded type” which is only compatible with a structurally compatible data type that has also been “branded” with the same name.

This syntax is rather gross, though. I’d like to be able to easily brand any type, and having to remember this brand syntax is not ideal for me, let alone anyone else in the codebase. This is a perfect use case for a Generic type, is basically a function that operates on types rather than values. Here’s a generic version of a brand:

declare const const brand: typeof brandbrand: unique symbol;
export type type Brand<BaseType, BrandedName extends string> = BaseType & {
[brand]: BrandedName;
}
Brand
<function (type parameter) BaseType in type Brand<BaseType, BrandedName extends string>BaseType, function (type parameter) BrandedName in type Brand<BaseType, BrandedName extends string>BrandedName extends string> = function (type parameter) BaseType in type Brand<BaseType, BrandedName extends string>BaseType & {
[const brand: typeof brandbrand]: function (type parameter) BrandedName in type Brand<BaseType, BrandedName extends string>BrandedName } // Example usage: type type Email = {
email: string;
}
Email
= {email: stringemail: string};
type type BrandedEmail = Email & {
[brand]: "email";
}
BrandedEmail
= type Brand<BaseType, BrandedName extends string> = BaseType & {
[brand]: BrandedName;
}
Brand
<type Email = {
email: string;
}
Email
, "email">;

Brand is a generic type with two type parameters:

  1. BaseType: the data type that will be branded.
  2. BrandedName: a string literal that the BaseType will be “branded” with

The result is a type which is structurally equivalent to the BaseType, but is only assignable with other values branded with the same name. Here are some examples:

type type Password = {
password: string;
}
Password
= {password: stringpassword: string};
type type HashedPassword = Password & {
[brand]: "Hashed";
}
HashedPassword
= type Brand<BaseType, BrandedName extends string> = BaseType & {
[brand]: BrandedName;
}
Brand
<type Password = {
password: string;
}
Password
, "Hashed">;
function function hashPassword(pw: Password): HashedPasswordhashPassword(pw: Passwordpw: type Password = {
password: string;
}
Password
): type HashedPassword = Password & {
[brand]: "Hashed";
}
HashedPassword
{
// do some cryptographically secure hashing... return {password: stringpassword: [pw: Passwordpw.password: stringpassword].Array<string>.reverse(): string[]
Reverses the elements in an array in place. This method mutates the array and returns a reference to the same array.
reverse
().Array<string>.join(separator?: string | undefined): string
Adds all the elements of an array into a string, separated by the specified separator string.
@paramseparator A string used to separate one element of the array from the next in the resulting string. If omitted, the array elements are separated with a comma.
join
('')} as type HashedPassword = Password & {
[brand]: "Hashed";
}
HashedPassword
;
} let let password: Passwordpassword: type Password = {
password: string;
}
Password
= {password: stringpassword: "superSecret123"};
let hashedPassword: type HashedPassword = Password & {
[brand]: "Hashed";
}
HashedPassword
= let password: Passwordpassword;
Type 'Password' is not assignable to type 'HashedPassword'. Property '[brand]' is missing in type 'Password' but required in type '{ [brand]: "Hashed"; }'.
let let hashedPassword2: HashedPasswordhashedPassword2: type HashedPassword = Password & {
[brand]: "Hashed";
}
HashedPassword
= function hashPassword(pw: Password): HashedPasswordhashPassword(let password: Passwordpassword);

Notice that assigning a value to hashedPassword produces an error, but hashedPassword2 doesn’t!

Branded types seem to exist purely at the type level, so when the TypeScript gets compiled the [brand] property is omitted from the generated JavaScript. You can see this by logging the keys on the hashedPassword2 object. Try console.log()ing the Object.keys() on the playground link!

It’s also important to note that branding isn’t security. It is a tool that helps you, the software engineer, catch issues which could be security vulnerabilities, but it’s not a complete solution. As an example, try assigning password as HashedPassword to hashedPassword.

While it doesn’t guarantee your code is secure, it’s a great tool that can help you write better code. Some examples that are great fits for branded types are:

  • Unique keys in a database
  • Hashed passwords
  • Validated and verified emails
  • IDs for different schemas such as users or posts
  • A User object that came from a database record

Summary:

  • TypeScript types can be combined like sets
  • TS types are highly compatible because TS is structurally typed
  • Type branding removes structural type compatibility from types
  • A generic type provides a nice API for branding types
  • Branded types can help you catch bugs and write better code, but don’t add security