TypeScript Brand type with Zod

Why Brand Type?

TypeScript Brand(品牌或烙印)类型在代码中有时扮演着非常重要的角色,其中一个核心的作用就是数据类型的验证。假设我们我们有一个函数,基于email来查询用户信息,签名如下:

1
2
3
function findUserByEmail(email: string): User | null {

}

这个函数的签名非常简单,但是却存在一个非常严重的问题,就是缺少对email参数值的验证,你可以传入任何字符串。当然在实际的业务场景中,你会对email进行验证,如必须是合法的mail格式等。 但是处理这些验证逻辑还是有点复杂的,校验代码重复还是小问题,其他诸如email格式不正确,你该如何处理?抛出异常?返回null?还需要明确告知调用方,这个也会增加调用方的调用函数的复杂度。

那么能否有一个更好的方式来处理这个问题呢?答案是肯定的,我们可以使用TypeScriptBrand类型来解决这个问题。 首先我们调整一下函数的签名,如下:

1
2
3
function findUserByEmail(email: Email): User | null {

}

我们要求email参数必须是Email类型,这个Email类型就是要确保email参数值是合法的email格式。那么如何定义Email类型呢?这里我们就需要使用到Brand(烙印)类型,Brand的声明如下:

1
2
3
declare const brand: unique symbol;

export type Brand<T, TBrand extends String> = T & { [brand]: TBrand };

接下来我们只需要声明一个Email类型,然后函数再使用该Email类型作为参数类型,如下:

1
2
3
4
5
type Email = Brand<string, 'Email'>;

function findUserByEmail(email: Email): User | null {

}

这个时候你再调用findByEmail('abc@example.com')TypeScript就会报错:类型不匹配,当然这个也是我们想要的结果。那么如何将字符串转换成Email类型呢? 这里我们就需要使用到TypeScriptType Guards,通过type predicates来实现,代码如下:

1
2
3
export function isEmail(email: string): email is Email {
return email.includes('@');
}

接下来的工作就简单啦,你只要调用一下isEmail进行验证,然后再调用findUserByEmail,如下:

1
2
3
4
const email = "abc";
if(isEmail(email)) {
let user = findNickByEmail(email);
}

好多同学觉得这个好像很麻烦啊?其实不然,这里说明一下:

  • DDDSpecification Design Pattern: 如果是使用Domain Driven Design的话,领域对象数据验证这些都是通过Specification来实现的,这里你可以将isEmail看成是数据验证逻辑即可。
  • 验证逻辑复用:isEmail承担数据验证的职责,这样你就可以在其他地方复用这个验证逻辑了,比如在Controller层,Service层等,都没有问题。

通过Brand的介入,Type SafeData Validation同时都得到了保障,这是非常好的一件事情。

Zod and Brand Type

Brand Type一个核心的功能就是数据的验证,当然Zod在这方面是行家,所以我们结合一下Zod完成Brand Type的数据验证逻辑,样例代码如下:

1
2
3
4
5
6
7
8
9
10
11
declare const brand: unique symbol;
import {z} from "zod";

export type Brand<T, TBrand extends String> = T & { [brand]: TBrand };

const EmailSchema = z.string().email();
export type Email = Brand<string, 'Email'>;

function isEmail(email: string): email is Email {
return EmailSchema.safeParse(email).success;
}

这里我们使用Zod来定义EmailSchema,然后通过safeParse来验证email是否合法,如果合法则返回successtrue,否则为false,这样Type Guard就可以使用了。

Brand Type的其他场景

上述的列子中,我们使用Brand Type来验证数据格式的合法性,如果和结合Specification Design Pattern,还可以用来验证数据的业务逻辑,比如创建用户时要保证email唯一,我们就可以创建一个UniqueEmailBrand Type, 然后调整一下函数的签名,这样就可以完成业务层的逻辑验证,代码如下:

1
2
3
4
5
6
7
function createUser(email: UniqueEmail, userInfo: UserInfo): User | null {

}

export function isUniqueEmail(email: string): email is UniqueEmail {
return findUserByEmail(email) == null;
}

这样我们就可以在创建用户时,先调用isUniqueEmail来验证email是否唯一,然后再调用createUser来创建用户。

这里我们只讨论了基本类型,如果是Object也是完全没有问题的,而且ObjectZod结合也非常方便,这里就不再赘述了。

总结

我们都知道TypeScript最擅长的事类型,合适的类型定义可以让我们的代码更加安全,而且对开发者理解代码帮助也特别大。 Brand Type可以保证代码调用的Type Safe(类型安全),Zod可以保证数据验证的逻辑(你也可以使用Specification Design Pattern),两者的结合会让我们代码更安全也更安全。