Branded Types
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: string
email: string;
password: string
password: string;
};
type type AuthSession = {
sessionId: string;
}
AuthSession = {
sessionId: string
sessionId: string;
}
type type AuthUser = User & AuthSession
AuthUser = 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: string
email: string;
password: string
password: string;
};
type type User = {
email: string;
password: string;
}
User = {
email: string
email: string;
password: string
password: string;
};
const const formdata: LoginFormInfo
formdata: type LoginFormInfo = {
email: string;
password: string;
}
LoginFormInfo = {
email: string
email: "me@domain.com",
password: string
password: "Secret123"
};
const const user: User
user: type User = {
email: string;
password: string;
}
User = const formdata: LoginFormInfo
formdata; // 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: string
email: string;
password: string
password: string;
}
type type AuthenticatedUser = {
email: string;
password: string;
}
AuthenticatedUser = {
email: string
email: string;
password: string
password: string;
}
They share the same structure, but the names imply very different meanings.
function function authnUser(user: UserLogin): AuthenticatedUser
authnUser(user: UserLogin
user: type UserLogin = {
email: string;
password: string;
}
UserLogin): type AuthenticatedUser = {
email: string;
password: string;
}
AuthenticatedUser {
// "Securely" hash the password
// and authenticate the user...
return user: UserLogin
user;
}
// Only authenticated users can perform this action
function function performSecureAction(user: AuthenticatedUser): void
performSecureAction(user: AuthenticatedUser
user: type AuthenticatedUser = {
email: string;
password: string;
}
AuthenticatedUser){
// ...
}
function function login(user: UserLogin): void
login(user: UserLogin
user: type UserLogin = {
email: string;
password: string;
}
UserLogin) {
const const authenticatedUser: AuthenticatedUser
authenticatedUser = function authnUser(user: UserLogin): AuthenticatedUser
authnUser(user: UserLogin
user);
function performSecureAction(user: AuthenticatedUser): void
performSecureAction(user: UserLogin
user);
}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 brand
brand: 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 namedbrand
.: unique symbol
The colon afterbrand
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 toconst
variables, so it’s important that we usedconst
rather thanlet
orvar
. Crucially, aunique 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 brand
brand: unique symbol;
type type Email = {
email: string;
}
Email = {email: string
email: string};
type type BrandedEmail = Email & {
[brand]: "email";
}
BrandedEmail = type Email = {
email: string;
}
Email & {[const brand: typeof brand
brand]: "email"};
In this example, I’m creating a type union with two parts:
- The data type I actually want
- 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 brand
brand: 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 brand
brand]: function (type parameter) BrandedName in type Brand<BaseType, BrandedName extends string>
BrandedName
}
// Example usage:
type type Email = {
email: string;
}
Email = {email: string
email: 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:
BaseType
: the data type that will be branded.BrandedName
: a string literal that theBaseType
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: string
password: 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): HashedPassword
hashPassword(pw: Password
pw: type Password = {
password: string;
}
Password): type HashedPassword = Password & {
[brand]: "Hashed";
}
HashedPassword {
// do some cryptographically secure hashing...
return {password: string
password: [pw: Password
pw.password: string
password].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): string
Adds all the elements of an array into a string, separated by the specified separator string.join('')} as type HashedPassword = Password & {
[brand]: "Hashed";
}
HashedPassword;
}
let let password: Password
password: type Password = {
password: string;
}
Password = {password: string
password: "superSecret123"};
let hashedPassword: type HashedPassword = Password & {
[brand]: "Hashed";
}
HashedPassword = let password: Password
password;let let hashedPassword2: HashedPassword
hashedPassword2: type HashedPassword = Password & {
[brand]: "Hashed";
}
HashedPassword = function hashPassword(pw: Password): HashedPassword
hashPassword(let password: Password
password);
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