react-hook-form 검증 타입에서 보는 제네릭(Generic)의 이점.
제네릭
제네릭은 C#, Java 등의 언어에서 재사용성이 높은 컴포넌트를 만들 때 자주 활용되는 특징이다.
제네릭(generic)이란 데이터의 타입을 일반화한다는 것을 의미하며 보통은 제네릭을 통해 클래스나 메소드에서 사용할 내부 데이터 타입을 지정하고, 컴파일 단계에서 type check를 수행하는 것이다. 제네릭 타입으로 프로그래머는 여러 타입의 컴포넌트나 자신만의 타입을 정의해 사용할 수 있다.
TypeScript 제네릭의 사용하는 이유와 이점은 여러가지가 존재하지만, 나는 코드 재사용성(Code Reusability)이 가장 큰 장점이자, JavaScript와 타입언어의 교집합적인 부분이라 생각한다. 제네릭은 다양한 타입에 대해 재사용할 수 있는 코드를 작성하는 데 도움이 된다. 하나의 제네릭 클래스를 작성하면 여러 타입에 대해 동일한 로직을 재사용할 수 있다. JavaScript는 따로 제네릭 처리없이 가능하지만, 타입이 런타임 때 정해지기 때문에 일어났던 에러가 보완 된 느낌이다.
이를 실제 상황에 적용한 케이스를 찾아보았다.
내가 자주 이용하는 react-hook-form은 input, textarea 같은 폼 상태관리를 일관적인 컴포넌트로 관리해 유지보수를 용이하게 해주는 라이브러리이다. 또한, 타입스크립트를 적극적으로 이용하고있다. 예시 코드도 jsx, tsx 별로 친절하게 제공하고 있으니 살펴보는 것을 추천한다. 많은 기능 중 필드 값의 오류를 검증하는 과정에서 제네릭을 어떻게 적용했는지 살펴보려고한다.
참고 코드 : https://github.com/react-hook-form/react-hook-form/blob/master/src/types/errors.ts#L15
react-hook-form/src/types/errors.ts at master · react-hook-form/react-hook-form
📋 React Hooks for form state management and validation (Web + React Native) - react-hook-form/react-hook-form
github.com
이러한 다양한 폼을 react-hook-form이 기본적으로 제공하는 validation 타입 이외에, 커스텀으로 지정한 에러 상황을 제공해야한다고 가정했을 때, 어떻게 유연하게 대처했을까? 그리고 개발자가 에러 없이 사용할 수 있게, 하지만 타입은 명확하게 할 수 있게 구성했을까?
유저의 간단한 정보를 입력받는 폼에 대한 Validation을 아래와 같이 선언한다고 가정해보자.
type MyFormValues = {
name: string;
age: number;
address: {
street: string;
city: string;
};
};
const errors: FieldErrors<MyFormValues> = {
name: {
type: "required",
message: "Name is required",
},
age: {
type: "min",
message: "Age must be at least 18",
},
address: {
street: {
type: "required",
message: "Street is required",
},
city: {
type: "required",
message: "City is required",
},
},
};
- DeepRequired
export type DeepRequired<T> = T extends BrowserNativeObject | Blob
? T
: {
[K in keyof T]-?: NonNullable<DeepRequired<T[K]>>;
};
- 객체 타입 T의 모든 속성을 필수로 만드는 유틸리티 타입.
- 네이티브 브라우저 객체나 Blob 타입은 그대로 유지한다.
- FieldErrorsImpl
해당 코드에서 FieldErrorsImpl 타입은 폼 필드 값에 대한 오류 타입을 정의하는 데 사용된다. 이 타입은 각 폼 필드 값이 글로벌 오류인지, 네이티브 브라우저 객체 오류인지, 중첩된 객체 오류인지, 또는 일반 필드 오류인지 구분하여 적절한 오류 타입을 할당한다. 이를 통해 폼 검증 로직을 보다 유연하고 명확하게 정의할 수 있다.
export type FieldErrorsImpl<T extends FieldValues = FieldValues> = {
[K in keyof T]?: T[K] extends BrowserNativeObject | Blob
? FieldError
: K extends 'root' | `root.${string}`
? GlobalError
: T[K] extends object
? Merge<FieldError, FieldErrorsImpl<T[K]>> //<-- 자기 자신을 호출
: FieldError;
};
F
ieldErrorsImpl<T> 타입은 T가 객체인 경우 재귀적으로 FieldError와 FieldErrorsImpl<T>를 병합하여 처리한다. 이는 복잡한 폼 구조에서도 유연하게 오류를 처리할 수 있게 한다.
- FieldErrorsImpl<T>가 처음 호출됨.
- T[K]가 객체라면, 다시 FieldErrorsImpl<T[K]> 호출됨(즉, FieldErrorsImpl<T[K]>가 다시 FieldErrorsImpl<U> 형태로 호출되므로 재귀적으로 타입이 확장)
- T[K]가 객체가 아니라면, FieldError로 변환되어 종료됨.
- 모든 중첩 객체에 대해 2~3번 과정이 반복됨.
- FieldErrors
export type FieldErrors<T extends FieldValues = FieldValues> = Partial<
FieldValues extends IsAny<FieldValues>
? any
: FieldErrorsImpl<DeepRequired<T>>
> & {
root?: Record<string, GlobalError> & GlobalError;
};
필드 값 T에 대한 오류를 나타내는 타입에 대해 각 필드 값에 대해 FieldError 또는 GlobalError를 사용할 수 있다.