Article summary
Branded types are awesome. Simply by using brands (and flavors), we can eliminate a whole class of bugs. Inside of our applications, we can rely on the type system to help prevent bugs. However, when we’re dealing with data at the edges of our applications, we need to verify (or, at least, cast) the data.
Often, this looks something like:
interface Branding<BrandT> {
_type: BrandT;
}
type Brand<T, BrandT extends string> = T & Branding<BrandT>;
export type UserId = Brand<string, "UserId">;
export type Email = Brand<string, "Email">;
const getUser = async (ctx: Context) => {
const user = await ctx.prisma.user.findUnique({
where: { id: ctx.session.user.id },
});
return {
...user,
id: user.id as UserId,
email: user.email as Email,
};
};
or even better with a type-guard:
export const toUserId = (id: string) => id as UserId;
export const toEmail = (email: string) => email as Email;
const getUser = async (ctx: Context) => {
const user = await ctx.prisma.user.findUnique({
where: { id: ctx.session.user.id },
});
return {
...user,
id: toUserId(user.id),
email: toEmail(user.email),
};
};
Still, this adds a lot of visual noise, and the extra overhead can turn off developers from using these tools. In this example, we’re not really validating the data, but we could (manually) in our type-guarded function.
Enter Zod.
This is where Zod can be hugely beneficial — not just for types, but actual data validation.
Zod has its own brand, which is a great starting point. It’s super easy to get started with, and it’s worth noting that it doesn’t affect the runtime result of a Zod parse
call. It’s strictly static data.
export const UserSchema = z.object({
id: z.string().min(1).brand("UserId"),
name: z.string().optional().nullable(),
email: z.string().email().brand("Email"), // note: this also works like .brand<"Email">()
});
And that’s it! We’ve successfully branded our data with Zod. This is a great place to start, but you may find that the Zod brand is incompatible with your own setup. Maybe you’re already using brands and can’t switch to the Zod brand, or perhaps you’re using a different method altogether (see: flavoring).
Let’s refine Zod.
Rather than using the Zod brand
call, we can use refine
, in conjunction with type-guarded functions.
This looks like:
export type UserId = Brand<string, "UserId">;
export const isUserId = (id: string): id is UserId => true; // let's let Zod handle the validation
export type Email = Brand<string, "Email">;
export const isEmail = (email: string): email is Email => true; // let's let Zod handle the validation
export const UserSchema = z.object({
id: z.string().min(1).refine(isUserId),
name: z.string().optional().nullable(),
email: z.string().email().refine(isEmail),
});
And just like that, we can use our own custom brands, flavors, or any other of our favorite nominal typing strategies.
Apply the brand.
Now that we have our UserSchema
, we can use it to simplify the processing and parsing of data, and get our branded types for free.
const getUser = async (ctx: Context) => {
const user = await ctx.prisma.user.findUnique({
where: { id: ctx.session.user.id },
});
return UserSchema.parse(user);
};
Not only is this method cleaner visually and more readable, it’s safer. That’s because we can use the powerful data validation tools that Zod offers.
Brands have been a game changer for me, and the combination with Zod has made using them even easier. I’d encourage you to give it a shot, even if you aren’t using either of these tools, as it helps us develop safe, bug-free code.