In a structural type system like TypeScript, functions that accept multiple arguments of the same type can sometimes lead to unexpected behavior. This is because TypeScript only considers the structure of the arguments, not their explicit type names, when determining type compatibility.
Here's an example that illustrates this potential issue:
type BlogId = string;
type PostId = string;
async function getPost(blogId: BlogId, postId: PostId) {
return posts.findOne({ blogId, postId });
}
// this code line compiles, but doesn't work correctly
const result = await getPost(postId, blogId);
What can we do about it?
Thanks to David, a colleague of mine, I've recently learned about two new techniques to avoid this problem - Branding and Flavoring.
Branding
Branding in TypeScript is a technique that allows you to create distinct types from any existing type, whether it's a primitive type (like string, number, or boolean) or a complex type (like an object, interface, class, or function). Branding is a form of nominal typing, which contrasts with TypeScript's default structural typing behavior.
With branding, you can introduce nominal typing for specific types in your codebase, ensuring that values representing different concepts or entities cannot be mixed accidentally, even if they share the same underlying structure or primitive type.
Let's see how this is done with our previous example:
// Branding a string
type PostId = string & { __brand: 'PostId' };
type BlogId = string & { __brand: 'BlogId' };
const postId: PostId = 'post-123' as PostId;
const blogId: BlogId = 'blog-456' as BlogId;
async function getPost(blogId: BlogId, postId: PostId) {
return posts.findOne({ blogId, postId });
}
// Doesn't compile
const result = await getPost(postId, blogId);
In this example, we brand the string
type by using an intersection type with an object literal that has a special __brand
property. PostId
and BrandId
are distinct branded types, even though their underlying primitive type is string
.
If we are using an input validation library like Zod, we can avoid casting when branding values:
const PostId = z.string().brand('PostId');
const BlogId = z.string().brand('BlogId');
// Branding a string
type PostId = z.infer<typeof PostId>
type BlogId = z.infer<typeof BlogId>
const postId: PostId = PostId.parse('post-123');
const blogId: BlogId = BlogId.parse('blog-456');
(checkout my blog posts series on Zod if you are not familiar with it)
While branding is useful for preventing careless mistakes, such as passing an incorrect value to the wrong positional argument of a function, it is not a foolproof solution It merely shifts the responsibility of properly constructing and branding values to the point where they are created. We can still make mistakes at that stage and inadvertently brand an incorrect value.
The problem with Branding
Branding has one caveat: we can no longer assign values to variables directly without 'blessing' them, either through casting or using a library like Zod. This can be problematic when writing tests or working with auto-generated code. For example:
type PostId = string & { __brand: 'PostId' };
const postId = "abc"; // This code doesn't compile
This issue is thoroughly explained in this great article and I highly recommend reading it. In the article, we are introduced to the Flavor solution. In short, flavoring is similar to branding but allows implicit conversion, addressing the issue we discussed earlier:
type Flavoring<FlavorT> {
_type?: FlavorT;
}
type Flavor<T, FlavorT> = T & Flavoring<FlavorT>;
type PostId = Flavor<string, 'PostId'>;
const postId: PostId = "abc"; // This code compiles
type BlogId = Flavor<string, 'BlogId'>;
const blogId: BlogId = postId; // But this still doesn't
Why this works is perfectly explained in the original article, so I'll just quote it:
TypeScript won’t let us mix our ID types because, under the hood, the
_type
properties are incompatible. The optional string "PostId" is not assignable to the optional string "BlogId". But because the_type
is optional, implicit conversion from unflavored values of the same underlying type is allowed. Once implicit conversion happens, however, TypeScript will henceforth treat the value as potentially having the declared_type
and disallow mixing with other, differently flavored types. Thus, downstream consumers of the value will get the safety and semantic benefits.
Using Flavor with Zod
An immediate question arises: can we use Flavors with Zod? When using Zod, we first define a schema, and then infer types based on that schema. How can we use the Flavor
type we wrote above as part of our Zod schemas?
To achieve that, we need to understand refining in Zod. Zod's refine method allows you to define a custom validation function for a schema, enabling you to enforce more complex validation rules beyond the built-in validations provided by Zod.
Here's a basic example of using Zod's refine
method:
import { z } from 'zod';
// Define a schema for a user's age
const ageSchema = z.number().refine(
(age) => age >= 18,
{ message: 'User must be at least 18 years old' }
);
// Usage
try {
const validAge = ageSchema.parse(20); // Valid age
console.log(validAge); // Output: 20
const invalidAge = ageSchema.parse(16); // Invalid age
} catch (error) {
console.error(error.message); // Output: User must be at least 18 years old
}
The first argument of the refine
method is a function that accepts a single argument, the validated input - a number
in this case, and returns a boolean
. Interestingly, if we pass a Type Guard function, the parsed input type is narrowed based on the type predicate defined for the type guard.
type BlogId = Flavor<string, 'BlogId'>;
function isBlogId(id: string): id is BlogId {
return !!id;
}
const BlogIdSchema = z.string().refine(isBlogId);
// The type of value is BlogId and not string
const value = BlogIdSchema.parse("123");
Summary
TypeScript is a structural type system, meaning it doesn't prevent us from passing an incorrect value to a function if the value's type matches the function parameter's type. A common technique to address this issue is Branding, which distinguishes between values with the same structure. While branding is useful, it makes value construction somewhat cumbersome. Flavoring is a more advanced approach that allows implicit conversion for easily constructing values while maintaining type safety. Zod provides built-in support for Branding. For Flavoring, we need to leverage type guard functions and Zod's refine method.
Top comments (0)