Last tended April 6, 2024.
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 Email
isEmail = (email: string
email: string):email: string
email is type Email = string & {
[brand]: "email";
}
Email=> { return email: string
email.String.includes(searchString: string, position?: number): 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.includes("@gmail.com") };
function function sendEmail(email: Email): void
sendEmail(email: Email
email: type Email = string & {
[brand]: "email";
}
Email) {
var console: Console
console.Console.log(...data: any[]): void
[MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log_static)log("sent.");
}
function function trySendEmail(email?: string): void
trySendEmail(email: string
email = "user@gmail.com"){
// @ts-expect-error
function sendEmail(email: Email): void
sendEmail(email: string
email); // string is not assignable to paramter of type `Email`
if( const isEmail: (email: string) => email is Email
isEmail(email: string
email) ) {
function sendEmail(email: Email): void
sendEmail(email: Email
email)
} else {
var console: Console
console.Console.log(...data: any[]): void
[MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log_static)log("not an email")
}
}
function trySendEmail(email?: string): void
trySendEmail()
In this example I make a “branded” Email
type and use the isEmail
function to narrow validated strings to Email
s which is useful in a case such as trySendEmail()
.
Branding can also be done with a reusable generic type:
declare const const brand: typeof brand
brand: 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 brand
brand]: 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: UserID
id: type UserID = number & {
[brand]: "User ID";
}
UserID; username: string
username: 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 = string
Password = string;
type type ValidPassword = string & {
[brand]: "Valid";
}
ValidPassword = type Brand<T, TBrand extends string> = T & {
[brand]: TBrand;
}
Brand<type Password = string
Password, "Valid">;
function function isValidPassword(password: Password): password is ValidPassword
isValidPassword(password: string
password: type Password = string
Password): password: string
password is type ValidPassword = string & {
[brand]: "Valid";
}
ValidPassword {
if( password: string
password.String.length: number
Returns the length of a String object.length >= 8) return true;
return false;
}
let let pass: string
pass: type Password = string
Password = "12345678";
if(function isValidPassword(password: Password): password is ValidPassword
isValidPassword(let pass: string
pass)){
let pass: ValidPassword
pass
// 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 ValidPassword
assertPasswordIsValid(password: string
password: type Password = string
Password): asserts password: string
password is type ValidPassword = string & {
[brand]: "Valid";
}
ValidPassword {
if(password: string
password.String.length: number
Returns the length of a String object.length < 8) throw new var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error("Password too short")
}
let let pass: string
pass: type Password = string
Password = "12345678";
function assertPasswordIsValid(password: Password): asserts password is ValidPassword
assertPasswordIsValid(let pass: string
pass);
let pass: ValidPassword
pass
// Hover over `pass` to see it's now typed as `ValidPassword` - no `if` statements needed!