10

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가지 옵션이 있다.

  1. 기본 값은 strip이다. 이 경우 여분의 키는 검증을 하지 않고 결과에도 포함시키지 않는다.
const result = personSchema.safeParse({
name: "zod",
age: 22,
hobby: "축구",
});
if (result.success) {
result.data;
// { name: "zod", age: 22 }
}
  1. 만약 여분의 키를 검증하지는 않지만 결과에 포함시키고 싶은 경우 passthrough 옵션을 사용할 수 있다.
const result = personSchema.passthrough().safeParse({
name: "zod",
age: 22,
hobby: "축구",
});
if (result.success) {
result.data;
// { name: "zod", age: 22, hobby: "축구" }
}
  1. 마지막으로 여분의 키를 허용하고 싶지 않은 경우 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가 내부적으로 어떻게 검증을 하고, 또 스키마로부터 타입을 추출할 수 있게 해주는지 알아보았다.