Type branding removes structural type compatibility

🌱February 3, 2024.
Last tended April 6, 2024.
budding 🌿
1 minute read ⏱

Chalk it up to growing up on C++, but sometimes I find it helpful to name a type and limit the usage based its name.

Consider an email address input. A user can type any string! I have no idea if they typed a valid email or not, all I know is it’s a string. Once I validate it with Zod I know more, but as far as TypeScript is concerned it’s still just a string. I could mistakenly assign something else to it, or accidentally use a different value when calling a function that requires a validated email. This is a problem in TypeScript because it uses structural types - data with the same shape is considered equivalent. This is in contrast to nominal types which are only compatible with objects that share the same type name.

Fortunately there’s a TypeScript technique called “Branded types” that makes it possible to reduce a structural type’s compatibility to the level of a nominal type. It is also a great way to encode information about a value in the type system.

/** `brand` is a globally unique value, so the only ways to create an "Email" type are 
* 1. "branding" a string using `as Email` 
* 2. copying an already branded `Email`
*/
declare const const brand: typeof brand
`brand` is a globally unique value, so the only ways to create an "Email" type are 1. "branding" a string using `as Email` 2. copying an already branded `Email`
brand
: unique symbol;
type type Email = string & { [brand]: "email"; }Email = string & { [const brand: typeof brand
`brand` is a globally unique value, so the only ways to create an "Email" type are 1. "branding" a string using `as Email` 2. copying an already branded `Email`
brand
]: "email"}
const const isEmail: (email: string) => email is EmailisEmail = (email: stringemail: string):email: stringemail is type Email = string & { [brand]: "email"; }Email=> { return email: stringemail.String.includes(searchString: string, position?: number | undefined): boolean
Returns true if searchString appears as a substring of the result of converting this object to a String, at one or more positions that are greater than or equal to position; otherwise, returns false.
@paramsearchString search string@paramposition If position is undefined, 0 is assumed, so as to search all of the String.
includes
("@gmail.com") };
function function sendEmail(email: Email): voidsendEmail(email: Emailemail: type Email = string & { [brand]: "email"; }Email) { var console: Consoleconsole.Console.log(...data: any[]): void
[MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log)
log
("sent.");
} function function trySendEmail(email?: string): voidtrySendEmail(email: stringemail = "[email protected]"){ // @ts-expect-error function sendEmail(email: Email): voidsendEmail(email: stringemail); // string is not assignable to paramter of type `Email` if( const isEmail: (email: string) => email is EmailisEmail(email: stringemail) ) { function sendEmail(email: Email): voidsendEmail(email: Emailemail) } else { var console: Consoleconsole.Console.log(...data: any[]): void
[MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log)
log
("not an email")
} } function trySendEmail(email?: string): voidtrySendEmail()

In this example I make a “branded” Email type and use the isEmail function to narrow validated strings to Emails which is useful in a case such as trySendEmail().

Branding can also be done with a reusable generic type:

declare const const brand: typeof brandbrand: unique symbol;
// The Brand generic can be used to augment any structural data type with nominal type safety
type type Brand<T, TBrand extends string> = T & {
    [brand]: TBrand;
}Brand<function (type parameter) T in type Brand<T, TBrand extends string>T, function (type parameter) TBrand in type Brand<T, TBrand extends string>TBrand extends string> = function (type parameter) T in type Brand<T, TBrand extends string>T & {[const brand: typeof brandbrand]: function (type parameter) TBrand in type Brand<T, TBrand extends string>TBrand}
type type Email = string & {
    [brand]: "Email";
}Email = type Brand<T, TBrand extends string> = T & {
    [brand]: TBrand;
}Brand<string, "Email"> // works the same as the `Email` type in the previous example
type type Password = string & {
    [brand]: "Password";
}Password = type Brand<T, TBrand extends string> = T & {
    [brand]: TBrand;
}Brand<string, "Password"> // Easy to reuse
type type UserID = number & {
    [brand]: "User ID";
}UserID = type Brand<T, TBrand extends string> = T & {
    [brand]: TBrand;
}Brand<number, "User ID"> // can be used with any primitive type
type type DBUser = {
    id: UserID;
    username: string;
} & {
    [brand]: "Database User";
}DBUser = type Brand<T, TBrand extends string> = T & {
    [brand]: TBrand;
}Brand<{id: UserIDid: type UserID = number & {
    [brand]: "User ID";
}UserID; username: stringusername: string}, "Database User">; // and with object types

The Brand utility type makes it easy to encode information about a value in its type. I find this helpful as values move through an application. Branded types can represent:

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

Narrowing to a branded type using type predicates and assertion functions

Brands can also be used with type predicate functions to narrow a structural type to a branded type in something like a validation function. For example:

type type Password = stringPassword = string;
type type ValidPassword = string & {
    [brand]: "Valid";
}ValidPassword = type Brand<T, TBrand extends string> = T & {
    [brand]: TBrand;
}Brand<type Password = stringPassword, "Valid">;

function function isValidPassword(password: Password): password is ValidPasswordisValidPassword(password: stringpassword: type Password = stringPassword): password: stringpassword is type ValidPassword = string & {
    [brand]: "Valid";
}ValidPassword {
	if( password: stringpassword.String.length: number
Returns the length of a String object.
length
>= 8) return true;
return false; } let let pass: stringpass: type Password = stringPassword = "12345678"; if(function isValidPassword(password: Password): password is ValidPasswordisValidPassword(let pass: stringpass)){ let pass: ValidPasswordpass // Hover `pass` above to see it's now typed as `ValidPassword` }

If you want to reduce the need for nested if statements or include guards, or prefer to treat a false case as an error/exception, consider using assertion functions instead:

function function assertPasswordIsValid(password: Password): asserts password is ValidPasswordassertPasswordIsValid(password: stringpassword: type Password = stringPassword): asserts password: stringpassword is type ValidPassword = string & {
    [brand]: "Valid";
}ValidPassword {
	if(password: stringpassword.String.length: number
Returns the length of a String object.
length
< 8) throw new var Error: ErrorConstructor new (message?: string | undefined, options?: ErrorOptions | undefined) => Error (+1 overload)Error("Password too short")
} let let pass: stringpass: type Password = stringPassword = "12345678"; function assertPasswordIsValid(password: Password): asserts password is ValidPasswordassertPasswordIsValid(let pass: stringpass); let pass: ValidPasswordpass // Hover over `pass` to see it's now typed as `ValidPassword` - no `if` statements needed!