z.object() 구현하기
- #1: z.string() 구현하기
- #3: z.string() min, max 메서드 구현하기
- #3: z.enum(), z.optional() 구현하기
- #4: z.object() 구현하기
오늘은 Zod가 어떻게 객체를 검증하는지 ZodObject
를 구현하면서 알아보자.
동작 확인하기
우선 다음과 같이 객체 스키마를 생성하고 데이터를 검증할 수 있다.
const personSchema = z.object({
name: z.string(),
age: z.number(),
});
personSchema.safeParse({
name: 'zod',
age: 22,
}) // 검증 성공
만약 타입이 다르거나 스키마에 있는 프로퍼티가 데이터에 없다면 검증에 실패한다.
personSchema.safeParse({
name: 'zod',
age: true,
}) // 검증 실패 - 숫자 대신 불리언이 들어옴
personSchema.safeParse({
name: 'zod',
}) // 검증 실패 - age가 없음
여분의 키(데이터에는 있지만 스키마에는 없는 키)가 있다면 3가지 옵션이 있다.
- 기본 값은
strip
이다. 이 경우 여분의 키는 검증을 하지 않고 결과에도 포함시키지 않는다.
const result = personSchema.safeParse({
name: "zod",
age: 22,
hobby: "축구",
});
if (result.success) {
result.data;
// { name: "zod", age: 22 }
}
- 만약 여분의 키를 검증하지는 않지만 결과에 포함시키고 싶은 경우
passthrough
옵션을 사용할 수 있다.
const result = personSchema.passthrough().safeParse({
name: "zod",
age: 22,
hobby: "축구",
});
if (result.success) {
result.data;
// { name: "zod", age: 22, hobby: "축구" }
}
- 마지막으로 여분의 키를 허용하고 싶지 않은 경우
strict
옵션을 사용할 수 있다.
personSchema.strict().safeParse({
name: "zod",
age: 22,
hobby: "축구",
}); // 검증 실패 - 여분의 키(hobby)가 포함되어 있음
구현해보기
이제 위에서 살펴본 대로 동작하도록 구현해 보자. 입력받는 객체 스키마를 shape
이라는 변수에 저장하고
여분의 키를 어떻게 처리할지에 대한 옵션을 extraKeyStrategy
라는 변수에 저장하면 다음과 같이 구현할 수 있다.
type ExtraKeyStrategy = "strip" | "passthrough" | "strict";
// shape은 어떤 것이 들어올지 모르기 때문에 타입 파라미터를 선언해 추론되도록 하고
// 키는 string, 값은 어떠한 ZodType 이어도 가능하도록 해주자.
// 또한 { name: ZodString.create() }으로 스키마를 생성했을 때 { name: string }이 추출되어야 하므로
// ZodType에 { [K in keyof T]: T[K]["_output"] }을 전달해 준다.
class ZodObject<T extends Record<string, ZodType<any>>> extends ZodType<{
[K in keyof T]: T[K]["_output"];
}> {
readonly shape: T;
readonly extraKeyStrategy: ExtraKeyStrategy;
constructor({
shape,
extraKeyStrategy,
}: {
shape: T;
extraKeyStrategy: ExtraKeyStrategy;
}) {
super();
this.shape = shape;
this.extraKeyStrategy = extraKeyStrategy;
}
_parse(
data: unknown
):
| { isValid: false; reason?: string | undefined }
| { isValid: true; data: { [K in keyof T]: T[K]["_output"] } } {
// 타입이 객체가 아니라면 더 이상 확인할 필요가 없다.
if (typeof data !== "object") {
return {
isValid: false,
};
}
// typeof null === 'object'이므로 예외 처리를 해준다.
if (data === null) {
return {
isValid: false,
};
}
// 마찬가지로 다음과 같은 경우 typeof data === 'object'이므로 예외 처리를 해준다.
// array, promise, date, set, map 일경우
// (코드 생략)
// shape key보다 data key가 적으면 더 이상 확인할 필요가 없다.
const shapeKeys = Object.keys(this.shape);
const dataKeys = Object.keys(data);
if (shapeKeys.length > dataKeys.length) {
return {
isValid: false,
};
}
// extraKeys => data에는 있지만 shape에는 없는 키
const extraKeys = [];
for (const key in data) {
if (!(key in this.shape)) {
extraKeys.push(key);
}
}
// strict일 경우 여분의 키를 허용하지 않으므로 예외 처리를 해준다.
if (this.extraKeyStrategy === "strict" && extraKeys.length > 0) {
return {
isValid: false,
};
}
for (const key in data) {
// 여분의 키가 아닌 것만 검증
if (!extraKeys.includes(key)) {
const result = this.shape[key]?._parse(data[key as keyof typeof data]);
if (result && !result.isValid) {
return {
isValid: false,
};
}
}
}
// strip일 경우 결과에 여분의 키를 포함시키지 않으므로 제거해 준다.
if (this.extraKeyStrategy === "strip") {
extraKeys.forEach((extraKey) => {
delete data[extraKey];
});
}
return {
isValid: true,
data,
};
}
// 생성 시 extraKeyStrategy의 기본값을 strip으로 설정해 준다.
static create<T extends Record<string, ZodType<any>>>(shape: T) {
return new ZodObject({ shape, extraKeyStrategy: "strip" });
}
}
마지막으로 extraKeyStrategy
를 변경하는 메소드를 추가해 준다.
class ZodObject<T extends Record<string, ZodType<any>>> extends ZodType<{
[K in keyof T]: T[K]["_output"];
}> {
// 생략
// strict, strip 과는 달리 여분의 키를 포함시키므로
// Infer로 타입 추출 시 여분의 키를 포함시킨다.
passthrough(): ZodType<
{
[K in keyof T]: T[K]["_output"];
} & { [k: string]: unknown }
> {
return new ZodObject({
shape: this.shape,
extraKeyStrategy: "passthrough",
});
}
strict() {
return new ZodObject({
shape: this.shape,
extraKeyStrategy: "strict",
});
}
strip() {
return new ZodObject({
shape: this.shape,
extraKeyStrategy: "strip",
});
}
}
이제 기존에 만든 클래스를 통해 확인하면 다음과 같은 결과를 얻을 수 있다.
기본값(strip
)인 경우 - 여분의 키를 검증하지 않고 결과에 포함 X
const personSchema = ZodObject.create({
name: ZodString.create(),
bloodType: ZodEnum.create(["A", "B", "AB", "O"]),
});
const result = personSchema.safeParse({
name: "zod",
bloodType: "A",
extra: 1,
}); // 검증 성공 { name: 'zod', bloodType: 'A' }
passthrough
인 경우 - 여분의 키를 검증하지 않고 결과에 포함 O
const personSchema = ZodObject.create({
name: ZodString.create(),
bloodType: ZodEnum.create(["A", "B", "AB", "O"]),
}).passthrough();
const result = personSchema.safeParse({
name: "zod",
bloodType: "A",
extra: 1,
}); // 검증 성공 { name: 'zod', bloodType: 'A', extra: 1 }
strict
인 경우 - 여분의 키를 허용하지 않음
const personSchema = ZodObject.create({
name: ZodString.create(),
bloodType: ZodEnum.create(["A", "B", "AB", "O"]),
}).strict();
const result = personSchema.safeParse({
name: "zod",
bloodType: "A",
extra: 1,
}); // 검증 실패 - extra가 포함되어 있으므로
추가 기능 구현하기
extend
위의 내용을 이해했다면 추가적인 기능을 구현하는 것은 어렵지 않다.
예를 들어 기존의 스키마를 기반으로 새로운 스키마를 만들고 싶을 때 사용하는 extend
메서드는 다음과 같이 구현할 수 있다.
class ZodObject<T extends Record<string, ZodType<any>>> extends ZodType<{
[K in keyof T]: T[K]["_output"];
}> {
// 생략
extend<T extends Record<string, ZodType<any>>>(shape: T) {
return new ZodObject({
// 새로운 shape을 기존의 shape에 추가해준다.
shape: { ...this.shape, ...shape },
extraKeyStrategy: this.extraKeyStrategy,
});
}
}
그러면 공통 스키마를 확장하여 사용할 수 있고 타입 추출까지 잘 되는 것을 확인할 수 있다.
const personSchema = ZodObject.create({
name: ZodString.create(),
bloodType: ZodEnum.create(["A", "B", "AB", "O"]),
});
const mySchema = personSchema.extend({
username: ZodString.create(),
});
const result = mySchema.safeParse({
name: "zod",
bloodType: "A",
username: "zod123",
}); // 검증 성공
Infer<typeof mySchema>;
// {
// name: string;
// bloodType: "A" | "B" | "AB" | "O";
// username: string;
// }
pick
이번에는 다음과 같이 스키마에서 원하는 프로퍼티만 선택하여 새로운 스키마를 생성하는 pick
메서드를 구현해 보자.
const mySchema = personSchema.pick({
name: true,
});
class ZodObject<T extends Record<string, ZodType<any>>> extends ZodType<{
[K in keyof T]: T[K]["_output"];
}> {
// 생략
// pick 메서드가 받는 인자를 mask라고 하자.
// mask의 타입은 리턴 타입에서 활용되어야 하기 때문에 타입 파라미터(Mask)로 선언해 준다.
// mask는 키가 shape의 키, 값은 true 여야 하고 일부만 입력되는 것이 허용되므로
// Partial을 이용하여 다음과 같이 제약사항을 준다.
pick<Mask extends Partial<Record<keyof T, true>>>(
mask: Mask
): ZodType<
// 리턴 타입의 경우 기존의 리턴 타입에서 주어진 Mask의 키만 뽑은 타입이 되어야한다. (Pick 사용)
Pick<
{
[K in keyof T]: T[K]["_output"];
},
// Pick의 두 번째 인자에는 첫 번째 인자의 키(keyof T)에 할당 가능한 값만 입력할 수 있다.
// 따라서 keyof Mask를 바로 전달할 수 없으므로 Extract를 활용하여 첫 번째 인자의 키(keyof T) 중
// keyof Mask에 할당 가능한 값만 뽑아내서 전달한다.
Extract<keyof T, keyof Mask>
>
> {
const newShape: any = {};
for (const key in mask) {
newShape[key] = this.shape[key];
}
return new ZodObject({
shape: newShape,
extraKeyStrategy: this.extraKeyStrategy,
}) as any;
}
}
결과
const personSchema = ZodObject.create({
name: ZodString.create(),
bloodType: ZodEnum.create(["A", "B", "AB", "O"]),
});
const mySchema = personSchema.pick({
name: true,
});
type Result = Infer<typeof mySchema>;
// {
// name: string;
// }
이렇게 해서 ZodString
, ZodOptional
, ZodEnum
, ZodObject
, Infer
등을 구현해 보면서 Zod가 내부적으로 어떻게 검증을 하고, 또
스키마로부터 타입을 추출할 수 있게 해주는지 알아보았다.