31 Aug 24
5 min read
842 words words
In TypeScript, a branded type is a way to create more specific types by adding an additional layer of type safety. It allows you to differentiate between types that are structurally identical but conceptually different by "branding" one or more of them with a unique identifier. This branding does not affect the runtime behavior but provides additional compile-time safety, preventing accidental misuse of the types.
I'll explain further with an example. Consider the code below:
const isEmailAddress = (email: string) => {
const emailRegexp = new RegExp(
/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
)
return emailRegexp.test(email)
}
const handleSignUp = (email: string) => {
// validate email
if (!isEmailAddress(email)) {
throw new Error(`${email} is not a valid email address`)
}
// Send welcome email
}
The drawbacks of this approach is that, 1. with this type of validation, we're forced to write an if
block anywhere we use our validation functions, such as the above isEmailAddress
. And 2. as far as the TypeScript complier is concerned, our email
variable is just a plain string
. Let me show you how we can make this implementation cleaner using TypeScript Branded types.
First, lets create our generic Brand
utility type.
type Brand<T> = T & { readonly __brand: unique symbol }
Here we are accepting our base type as an argument T
and attaching an arbitrary object to it, which does affect the runtime behaviour of our variables at all.
Now that we have our Brand
type let's go ahead and use it to create our branded email address type like below.
type EmailAddress = Brand<string>
This is what you'll see when you hover over the EmailAddress
type in VS Code.
Looks a bit weird, but we won't have to worry about the attached object at all when we need to work with our branded types. The readonly brand: unique symbol
part is the branding mechanism. The unique symbol ensures that each brand is unique and can't be confused with others.
Next, lets create our sendWelcomeEmail
function that take an argument of type EmailAddress
.
const sendWelcomeEmail = (email: EmailAddress) => {
// Send welcome email to a valid email address
}
The nice thing here is that this function no longer needs to worry about validating the email that's passed to it, since by this point we'll have already taken care of that.
If you now use this function within our handleSignUp
function, you'll see a TypeScript error that says Argument of type 'string' is not assignable to parameter of type 'EmailAddress'.
and that's expected for now, since we haven't really told TypeScript how to determine whether our email
variable is really of type EmailAddress
or not.
To fix this issue, all we have to do is make our isEmailAddress
function a "type guard" by adding : email is EmailAddress
return type to it. See below:
const isEmailAddress = (email: string): email is EmailAddress => {
const emailRegexp = new RegExp(
/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
)
return emailRegexp.test(email)
}
By specifying email is EmailAddress
as the return type of this function, we're telling the typescript compiler that if this function returns true
, the email argument must be of type EmailAddress
!
Cool! Now we have a type-safe way of making sure that a string is a valid email address. But let's make this implementation even cleaner. We can get rid of that repetitive if
block when working with the branded type.
To do that, we'll create an assertion function that abstracts away that part of the logic for us.
function assertEmailAddress(email: string): asserts email is EmailAddress {
if (!isEmailAddress(email)) {
throw new Error(`${email} is not a valid email address`)
}
}
Just a quick note: TypeScript doesn't support using arrow functions for assertions, so we'll need to use a regular function here.
Now, we can use our new assertEmailAddress
function to tidy up our handleSignUp
function.
const handleSignUp = (email: string) => {
// Assert email address
assertEmailAddress(email)
// Send welcome email
sendWelcomeEmail(email)
}
And that's it! This was a quick look at how you can use TypeScript Branded types. If you've used this pattern before, let me know what cool use cases you've come across. And how I can improve my implementation.
The final state of the code:
type Brand<T> = T & { readonly __brand: unique symbol }
type EmailAddress = Brand<string>
const isEmailAddress = (email: string): email is EmailAddress => {
const emailRegexp = new RegExp(
/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
)
return emailRegexp.test(email)
}
const sendWelcomeEmail = (email: EmailAddress) => {
// Send welcome email to a valid email address
}
function assertEmailAddress(email: string): asserts email is EmailAddress {
if (!isEmailAddress(email)) {
throw new Error(`${email} is not a valid email address`)
}
}
const handleSignUp = (email: string) => {
// Assert email address
assertEmailAddress(email)
// Send welcome email
sendWelcomeEmail(email)
}
Thanks for tagging a long. Until next time