z.string() 구현하기
- #1: z.string() 구현하기
- #3: z.string() min, max 메서드 구현하기
- #3: z.enum(), z.optional() 구현하기
- #4: z.object() 구현하기
Zod 기본 동작 확인하기
Basic usage를 보면 Zod는 다음과 같이 스키마를 생성하고
parse
또는 safeParse
메서드를 사용하여 올바른 input이 들어왔는지 검증한다.
import { z } from "zod";
// creating a schema for strings
const mySchema = z.string();
// parsing
mySchema.parse("tuna"); // => "tuna"
mySchema.parse(12); // => throws ZodError
// "safe" parsing (doesn't throw error if validation fails)
mySchema.safeParse("tuna"); // => { success: true; data: "tuna" }
mySchema.safeParse(12); // => { success: false; error: ZodError }
ZodType 클래스 생성하기
일단 parse
, safeParse
와 같은 공통 메서드는 부모 클래스(ZodType
)에서 구현하고
ZodString
은 이를 상속하여 구현해 보자.
parse
메서드의 경우 내부적으로 safeParse
메서드를 이용하여 구현되어 있기 때문에 safeParse
만 구현해 보겠다.
safeParse
가 받는 인자는 검증하고 싶은 데이터이고 어떤 데이터 타입인지 알 수 없으므로 다음과 같이 정의할 수 있다.
class ZodType {
safeParse(data: unknown) {}
}
이제 코드를 작성하기 전에 로직을 주석으로 작성해 보자.
class ZodType {
safeParse(data: unknown) {
// 검증이 성공했다면
// return { success: true, data };
// 검증이 실패했다면
// return { success: false, error: new Error("검증 실패") };
}
}
검증이 성공했는지 확인하는 로직은 특정 타입(string인지, number인지...)에 따라 다를 텐데 이 로직을 ZodType
클래스에서 어떻게 구현할 수 있을까?
추상 메서드 활용하면 이를 구현할 수 있다. 추상 메서드는 정의만 있을 뿐 몸체는 구현되어 있지 않다. 몸체는 해당 추상 클래스를 상속받은 클래스에서 구현하면 된다.
즉, ZodType을 추상 클래스로 만들고(추상 메서드는 추상 클래스에서만 사용할 수 있다.) _parse
를 추상 메서드로 만들어 정의만 작성한 후, 몸체는 추후 ZodType
을 상속받은 ZodString
에서 구현하면 된다.
그러면 검증 성공 여부를 확인하는 로직을 _parse
라는 내부 추상 메서드에서 하도록 하고 정의를 작성해 보자.
(클래스 내부에서만 사용되는 프로퍼티 또는 메서드임을 나타내기 위해 관습적으로 변수명 앞에 _를 붙이곤 한다.)
abstract class ZodType {
abstract _parse() {}
// 생략
}
어떤 인자를 받고 어떤 값을 리턴해야 할까?
우선 데이터를 검증해야 하므로 data
를 인자로 받고
- 검증이 성공했다면 성공했다는 것을 알려주고 해당 데이터를 반환한다.
- 검증이 실패했다면 실패했다는 것을 알려주고 왜 실패했는지를 반환한다.
추상 메서드에서는 정의만 작성하면 되므로 다음과 같이 작성할 수 있다.
abstract class ZodType {
abstract _parse(
data: unknown
): { isValid: true; data: unknown } | { isValid: false; reason?: string };
safeParse(data: unknown) {
// 검증이 성공했다면
// return { success: true, data };
// 검증이 실패했다면
// return { success: false, error: new Error("검증 실패") };
}
}
이제 해당 정의를 바탕으로 주석을 코드로 변경해 보자.
abstract class ZodType {
abstract _parse(
data: unknown
): { isValid: true; data: unknown } | { isValid: false; reason?: string };
safeParse(data: unknown) {
const result = this._parse(data);
if (result.isValid) {
return { success: true, data: result.data };
} else {
return { success: false, error: new Error(result.reason ?? "검증 실패") };
}
}
}
ZodString 클래스 생성하기
이제 ZodType
을 상속받은 ZodString
클래스를 만들고 추상 메서드 _parse
를 구현해 보자.
class ZodString extends ZodType {
_parse(
data: unknown
):
| { isValid: true; data: unknown }
| { isValid: false; reason?: string } {
if (typeof data === "string") {
return {
isValid: true,
data,
};
} else {
return {
isValid: false,
reason: `${data}는 string이 아닙니다.`,
};
}
}
}
이제 string을 검증할 수 있게 되었으니 결과를 확인해 보자.
const mySchema = new ZodString();
// 올바른 데이터가 들어왔을 경우
const result = mySchema.safeParse("1");
// ^? { success: true, data: '1' }
// 올바르지 않은 데이터가 들어왔을 경우
const result = mySchema.safeParse(1);
// ^? { success: false, error: Error: 1는 string이 아닙니다. ... }
위와 같이 주어진 데이터가 string 인지 아닌지를 잘 검증하고 있는 것을 확인할 수 있다.
safeParse 메서드의 타입 문제 해결하기
타입이 좁혀지지 않는 이슈
현재는 result.success: true
인 경우에도 타입이 좁혀지지 않고 있다.
이러면 컴파일 타임에 어떤 필드가 있는지 정확하게 확인할 수 없기 때문에 실수할 가능성이 커진다.
컴파일 타임에 미리 방지할 수 있는 버그를 런타임이 되어서야 발견하는 일을 방지하기 위해 해당 코드를 개선해 보자.
const mySchema = new ZodString();
const result = mySchema.safeParse("1");
if (result.success) {
// 이전에는 `safeParse`의 리턴 타입을 명시하지 않았기 때문에
// 타입스크립트가 자동으로 추론해 주고 있었다.
result
// ^?
// { success: boolean; data: unknown; error?: undefined; }
// | { success: boolean; error: Error; data?: undefined; }
}
성공했을 경우 data
만 반환하고 실패했을 경우 error를 반환할 것이므로
discriminated union을 이용하여 다음과 같이 리턴 타입을 작성할 수 있다.
abstract class ZodType {
// 생략
safeParse(
data: unknown
): // success라는 공통 필드를 리터럴 타입(true 또는 false)으로 작성해주고
// 각각의 경우 어떠한 필드와 타입를 반환할 것인지 작성해준다.
{ success: true; data: unknown } | { success: false; error: Error } {
// 생략
}
}
그러면 다음과 같이 result.success
가 true
인 스코프에서는 data
만 있다고 알려준다.
const mySchema = new ZodString();
const result = mySchema.safeParse("1");
if (result.success) {
result
// ^? { success: true; data: unknown }
}
data의 타입이 올바르게 추론되지 않는 이슈
하지만 아직 해결해야 할 문제가 남아있다.
“1”
을 입력하였고 성공 시 해당 데이터를 그대로 반환해 주므로 data
는 string
타입으로 추론되어야 하지만 unknown
타입으로 추론되고 있다.
그 이유는 당연하게도 성공했을 시 data
필드를 반환하고 이 필드는 unknown
타입이라고 선언해 주었기 때문이다. 조금 더 구체적인 타입을 반환하려면 어떻게 타이핑을 해야 할까?
safeParse(
data: unknown
): { success: true; data: unknown } | { success: false; error: Error }{
// 생략
}
ZodType
클래스에서는 반환하는 data
가 어떤 타입인지 알 수 없다.
하지만 ZodString
클래스에서는 반환하는 data
가 string
임을 알고 있다.
그러면 ZodString
클래스를 생성할 때 ZodType
에게 반환하는 값을 알려주면 될 것 같다.
// Output이라는 이름으로 타입 파라미터를 선언하고 반환하는 data가 Ouput 타입임을 알려준다.
abstract class ZodType<Output> {
abstract _parse(
data: unknown
): { isValid: true; data: Output } | { isValid: false; reason?: string };
safeParse(
data: unknown
): { success: true; data: Output } | { success: false; error: Error } {
// 생략
}
}
// ZodString 클래스는 반환하는 값이 string 타입임을 알고있다. 따라서 타입 인자 string을 넘겨준다.
class ZodString extends ZodType<string> {
_parse(
data: unknown
):
| { isValid: true; data: string }
| { isValid: false; reason?: string | undefined } {
// 생략
}
}
이러면 data
가 unknown
으로 추론되는 문제도 해결되었다.
const mySchema = new ZodString();
const result = mySchema.safeParse("1");
if (result.success) {
result
// ^? { success: true; data: string }
}
스키마로부터 타입 추출하기
이번 글의 마지막으로 z.infer처럼 스키마로부터 타입스크립트 타입을 추출할 수 있도록 도와주는 유틸리티 타입을 구현해 보자.
import { z } from "zod";
const mySchema = z.string();
type MySchemaType = z.infer<typeof mySchema>
// ^? string
타입 파라미터 T
는 ZodType
을 확장한다는 것을 이용하면 다음과 같이 Output
타입을 추출할 수 있다.
type Infer<T extends ZodType<unknown>> = Extract<
ReturnType<T["_parse"]>,
{ isValid: true }
>["data"];
실제 Zod는 훨씬 복잡도가 높기 때문에 위와 같이 구현되어 있지 않고 ZodType
클래스에 _output
이라는 readonly
프로퍼티를 만들고 이 프로퍼티의 타입을 Output
으로 설정해 준다. 특이한 점은 해당 프로퍼티는 타입을 저장하고 읽기 위해서만 사용하고 실제로 값을 저장하지는 않는다.
abstract class ZodType<Output> {
// 타입을 Output으로 입력해주었지만 실제로 생성자에서 값을 할당하지는 않는다.
// 따라서 다음과 같은 타입스크립트 에러가 발생한다.
// Property '_output' has no initializer and is not definitely assigned in the constructor.
readonly _output: Output;
// 생략
}
코드 작성자가 의도적으로 타입만 저장하기 위해 작성한 코드이기 때문에 non-null 단언 사용자를 통해 해당 프로퍼티는 null
이 아님을 알려준다.
abstract class ZodType<Output> {
// 타입스크립트는 해당 프로퍼티가 null이 아니라고 생각하기 때문에 생성자에서 값을 할당해주지 않아도 에러가 발생하지 않는다.
readonly _output!: Output;
// 생략
}
이러면 아까 만들었단 Infer 타입을 다음과 같이 간단하게 변경할 수 있다. (해당 프로퍼티는 실제로는 어떠한 값도 할당되어 있지 않기 때문에 타입을 얻기 위해서만 사용해야 한다.)
type Infer<T extends ZodType<unknown>> = T['_output'];