7

z.enum(), z.optional() 구현하기

이전 글에서는 간단한 string 타입을 검증할 수 있는 ZodString 클래스를 구현해 봤습니다. 오늘은 enum 타입을 검증할 수 있는 ZodEnum과 선택적으로 인풋을 받기 위해 사용하는 ZodOptional을 구현해 보겠습니다.

ZodEnum

Zod enum을 사용하면 enum 스키마를 생성할 수 있다.

const FishEnum = z.enum(["Salmon", "Tuna", "Trout"]);
type FishEnum = z.infer<typeof FishEnum>;
// "Salmon" | "Tuna" | "Trout"

입력받은 data가 FishEnum 타입과 다르다면 다음과 같이 검증에 실패한다.

const result1 = FishEnum.safeParse("Salmon");
// { success: true, data: "Salmon" }
const result2 = FishEnum.safeParse("Salmon2");
// { success: false, error: Error }

구현해보기

이제 어떻게 사용하는지 파악했으므로 간단한 버전으로 구현해 보자.

// 2. values는(ex. ["Salmon", "Tuna", "Trout"]) 하나 이상의 string으로
// 이루어진 배열이므로 타입 파라미터 T를 다음과 같이 선언할 수 있다.
// 또한 추론되어야 하는 타입은 배열의 멤버이므로 ZodType의 파라미터에 T[number]를 인자로 전달해 준다.
class ZodEnum<T extends [string, ...string[]]> extends ZodType<T[number]> {
// 1. 허용된 enum 값을 저장하는 변수
readonly values: T;
constructor(values: T) {
super();
this.values = values;
}
_parse(
data: unknown
):
| { isValid: false; reason?: string | undefined }
| { isValid: true; data: T[number] } {
// 3. data가 string이 아니라면 더 이상 확인하지 않는다.
if (typeof data !== "string") {
return {
isValid: false,
};
}
// 4. values에 data가 없다면 유효하지 않다.
if (!this.values.includes(data)) {
return {
isValid: false,
};
}
// 5. 위에서 걸러지지 않았다면 유효하다.
return {
isValid: true,
data,
};
}
static create<T extends [string, ...string[]]>(values: T) {
return new ZodEnum(values);
}
}

이렇게 구현을 하면 검증 로직이 잘 동작하는 것을 확인할 수 있다.

const FishEnum = ZodEnum.create(["Salmon", "Tuna", "Trout"]);
FishEnum.safeParse("Trout"); // 통과
FishEnum.safeParse("Trout!!"); // 실패

하지만 지난 글에서 만든 Infer로 타입을 추출해 보면 string으로 잘못 추론되고 있다. 우리가 원하는 타입 "Salmon" | "Tuna" | "Trout"과는 다르다.

type FishEnum = Infer<typeof FishEnum>;
// ^? string

이를 개선하기 위해 create 메서드의 타입을 변경해 보자. 현재는 단순히 string으로 이루어진 배열을 확장하는 타입으로 타입 파라미터가 선언되어 있기 때문에 타입스크립트가 멤버의 타입을 string으로 추론해 주었다.

static create<T extends [string, ...string[]]>(values: T) {
return new ZodEnum(values);
}

하지만 우리는 string보다 더 구체적인 타입으로 추론해 주길 원하기 때문에 다음과 같은 트릭을 사용할 수 있다. string 타입을 확장하는 새로운 타입 파라미터 U를 선언해 주고 기존의 TU로 이루어진 배열을 확장하도록 변경해 준다.

static create<U extends string, T extends [U, ...U[]]>(values: T) {
return new ZodEnum(values);
}

그러면 타입스크립트가 배열의 멤버를 string 타입보다 조금 더 구체적인 타입으로 추론해 주기 때문에 원하는 결과를 얻을 수 있다.

type FishEnum = Infer<typeof FishEnum>;
// ^? "Salmon" | "Tuna" | "Trout"

ZodOptional

스키마를 선언하다 보면 있을 수도, 없을 수도 있는 필드를 검증하고 싶을 때가 있다.

예를 들면 사용자에게 주소를 선택적으로 입력하도록 만들고 해당 데이터를 검증한다고 하자.

const optionalAddress = z.string().optional(); // string | undefined
// 다음과 같이 선언할 수도 있다.
const optionalAddress = z.optional(z.string()); // string | undefined

그러면 z.string()으로 스키마를 선언했을 때와는 다르게 데이터가 입력되지 않았더라도(undefined여도) 검증이 실패하지 않는다.

optionalAddress.safeParse("서울특별시..."); // 통과
optionalAddress.safeParse(undefined); // 통과

구현 해보기

// 2. innerType에는 어떠한 값이 들어올지 모르기 때문에 타입 파라미터 T를 선언하고
// 이는 ZodType을 확장하는 값만 허용하도록 해준다.
// 또한 해당 스키마의 타입을 추출했을 때 ZodString 타입이 입력되었다면 string | undefined로
// 추론되어야 하므로 ZodType에 T["_output"] | undefined을 인자로 전달해 준다.
class ZodOptional<T extends ZodType<any>> extends ZodType< T["_output"] | undefined > {
// 1. 기존에 어떠한 타입인지를 저장하는 변수
// ZodString, ZodEnum 등 ZodType을 확장한다면 모두 가능하다.
readonly innerType: T;
constructor(innerType: T) {
super();
this.innerType = innerType;
}
_parse(
data: unknown
):
| { isValid: false; reason?: string | undefined }
| { isValid: true; data: T | undefined } {
// 3. undefined이면 더이상 확인할 필요 없이 통과
if (typeof data === "undefined") {
return {
isValid: true,
data,
};
}
// 4. undefined가 아니라면 innerType에게 역할을 넘김
return this.innerType._parse(data);
}
static create<T extends ZodType<any>>(innerType: T) {
return new ZodOptional(innerType);
}
}

이러면 주어진 타입을 선택적으로 입력받도록 만들 수 있고

const optionalAddress = ZodOptional.create(ZodString.create());
optionalAddress.safeParse("서울특별시..."); // 통과
optionalAddress.safeParse(undefined); // 통과
const optionalFish = ZodOptional.create(
ZodEnum.create(["Salmon", "Tuna", "Trout"])
);
optionalFish.safeParse("Salmon");
optionalFish.safeParse(undefined); // 통과

타입 추출 또한 잘 되게 만들 수 있다.

Infer<typeof optionalAddress>
// ^? string | undefined
Infer<typeof optionalFish>
// ^? "Salmon" | "Tuna" | "Trout" | undefined

주어진 타입을 선택적으로 받도록 만드는 기능은 모든 타입에서 공통적으로 사용할 수 있기 때문에 ZodType의 메서드에 추가하면 더 편하게 사용 가능하다.

abstract class ZodType<Output> {
// 생략
optional() {
return ZodOptional.create(this);
}
}
const optionalAddress = ZodString.create().optional();
const optionalFish = ZodEnum.create(["Salmon", "Tuna", "Trout"]).optional();

비록 매우 단순화시킨 코드지만 지금까지 코드를 이해한다면 ZodNumber, ZodArray, ZodNullable 등도 어떻게 구현되어 있을지 예상할 수 있을 것이다. 다음 글에서는 마지막으로 ZodObject를 어떻게 구현하는지 알아보자.