2025년 11월, yup → zod 마이그레이션 과정에서
기존 FormType과 zod schema의 추론 타입이 어긋나는 문제가 발생했고
이를 schema 기준 타입 추론 구조로 정리한 기록입니다.
기존 프로젝트에서는 form 타입을 별도의 FormType으로 정의하고, 유효성 검사는 yup schema로 관리하고 있었다.
이후 validation 라이브러리를 yup에서 zod로 전환하면서 기존 FormType은 유지한 채 zod schema를 추가했다.
이때 FormType과 zod schema가 서로 다른 구조를 가져도, zodResolver만으로는 두 타입의 불일치를 직접 확인하기 어려웠다.
런타임 유효성 검사는 동작하지만, 컴파일 단계에서 form 타입과 schema 타입이 실제로 일치하는지 보장하기 어려운 상황이었다.
마이그레이션 상황
1. 문제 상황
기존 form 코드는 대략 아래와 같은 구조였다.
type FormType = {
title: string;
image: File | null;
};
const schema = z.object({
title: z.string().trim().min(1, "필수값입니다"),
image: z.union([z.string(), z.null()]),
});겉으로 보기에는 큰 문제가 없어 보일 수 있다.
하지만 FormType의 image는 File | null이고, schema의 image는 string | null이다.
type FormTypeImage = File | null;
type SchemaImage = string | null;이처럼 form에서 사용하는 타입과 schema에서 추론되는 타입이 달라도 zodResolver는 두 타입의 일치 여부를 비교하지 않는다.
문제는 이런 불일치가 바로 눈에 띄지 않는다는 점이었다.
2. 재현 케이스
아래처럼 useForm에 기존 FormType을 전달하고, resolver에는 zod schema를 연결한다고 가정한다.
const form = useForm<FormType>({
resolver: zodResolver(schema),
});이 코드에서 zodResolver(schema)는 schema 기준으로 validation을 수행한다.
하지만 useForm<FormType>에 전달한 타입과 z.infer<typeof schema>가 같은지까지 확인하지는 않는다.
type FormType = {
title: string;
image: File | null;
};
type SchemaType = {
title: string;
image: string | null;
};따라서 image처럼 서로 다른 타입이 섞여 있어도, form 코드가 많거나 구조가 깊으면 마이그레이션 중에 놓치기 쉬웠다.
3. 원인 분석
FormType과 schema의 이중 관리
기존 구조에서는 form에서 사용하는 타입과 validation schema가 분리되어 있었다.
type FormType = {
name: string;
type: string | undefined;
};
const schema = z.object({
name: z.string(),
type: z.union([z.string(), z.undefined()]),
});FormType은 form 데이터 타입을 표현하고, schema는 유효성 검사를 담당한다.
하지만 두 정의를 따로 관리하면 한쪽만 수정되었을 때 타입이 쉽게 어긋난다.
zodResolver의 역할 범위
zodResolver는 zod schema를 기반으로 validation을 수행한다.
const form = useForm<FormType>({
resolver: zodResolver(schema),
});하지만 이 코드만으로는 FormType과 schema의 추론 타입이 같은지 확인할 수 없다.
즉, 아래 두 타입이 실제로 일치하는지 보장되지 않는다.
type FormType = {
title: string;
};
type SchemaType = z.infer<typeof schema>;문제의 원인은 zodResolver 자체가 아니라, FormType과 schema를 별도로 관리하던 구조에 있었다.
수동 검증의 한계
가장 단순한 방법은 모든 form 코드를 직접 열어서 확인하는 것이다.
하지만 마이그레이션 상황에서는 이 방식에 한계가 있었다.
- form 수가 많다.
- 필드 구조가 깊은 객체도 있다.
- 기존 yup schema와 FormType의 신뢰도를 모두 확신하기 어렵다.
- 마이그레이션 중에는 기존 코드와 신규 코드가 공존한다.
결국 직접 확인만으로는 누락을 막기 어렵다고 판단했다.
1차 해결 방안: strictZodResolver
마이그레이션 당시에는 기존 FormType이 실제 form 값과 항상 일치한다고 확신하기 어려웠다.
그렇다고 모든 form 타입을 한 번에 schema 기준으로 바꾸기에는 영향 범위가 컸다.
그래서 먼저 기존 FormType을 유지한 상태에서 schema 타입과의 불일치를 컴파일 단계에서 확인하는 방법을 시도했다.
이를 위해 strictZodResolver라는 함수를 만들었다.
핵심 아이디어는 아래와 같았다.
- zod schema에서
z.infer로 타입을 추론한다. - 기존
FormType과 schema 추론 타입을 비교한다. - 두 타입이 다르면 타입 에러를 발생시킨다.
- runtime validation은 기존
zodResolver를 그대로 사용한다.
1. 도입 이유
strictZodResolver는 기존 타입을 최종 구조로 확정하기 전에, FormType과 zod schema의 추론 타입이 어긋나는 지점을 먼저 드러내기 위한 임시 안전장치였다.
마이그레이션 초기에 이 과정을 거쳐 타입 불일치 지점을 정리한 뒤, 최종적으로는 일반 zodResolver로 교체하는 흐름을 의도했다.
2. 타입 비교 유틸
먼저 두 타입이 동일한지 비교하는 타입 유틸리티를 작성했다.
type IsEqualType<A, B> = [A] extends [B]
? [B] extends [A]
? true
: false
: false;비교 대상 타입을 튜플로 감싸서 union 타입이 분배 조건부 타입으로 나뉘지 않도록 했다.
string | null 같은 union 타입도 전체 타입을 하나의 비교 대상으로 다루기 위해서였다.
3. FormType과 schema 비교
각 필드의 타입이 일치하는지 확인하기 위해 CheckTypeMatch를 작성했다.
type CheckTypeMatch<K extends string, TForm, TZodInferred> =
IsEqualType<TForm, TZodInferred> extends true
? TForm
: TForm extends TZodInferred
? `Error: FormType의 '${K}'의 타입의 범위가 더 넓습니다!`
: TZodInferred extends TForm
? `Error: Zod schema의 '${K}'의 타입의 범위가 더 넓습니다!`
: `Error: FormType와 Zod schema의 '${K}' 타입이 일치하지 않습니다.`;이 타입은 FormType의 필드 타입과 schema에서 추론한 필드 타입을 비교한다.
두 타입이 완전히 동일하면 기존 TForm 타입을 그대로 반환하고, 다르면 에러 메시지 문자열 타입을 반환하도록 했다.
key 누락도 함께 확인하기 위해 CheckFormSchemaMatch를 추가했다.
type CheckFormSchemaMatch<TForm, TSchema extends z.ZodType<any, any, any>> =
z.infer<TSchema> extends infer TZodInferred
? {
[K in keyof TForm]: K extends keyof TZodInferred
? CheckTypeMatch<K & string, TForm[K], TZodInferred[K]>
: `Error: Zod schema의 '${K & string}' key 값이 누락되었습니다!`;
} & {
[K in keyof TZodInferred]: K extends keyof TForm
? TZodInferred[K]
: `Error: FormType의 '${K & string}' key 값이 누락되었습니다!`;
}
: never;이 타입은 FormType → Schema, Schema → FormType 양방향으로 key와 필드 타입을 비교한다.
4. resolver 연결
마지막으로 위 타입 유틸을 zodResolver에 연결했다.
export const strictZodResolver = <
TSchema extends z.ZodType<any, any, any>,
TForm extends CheckFormSchemaMatch<TForm, TSchema>,
>(
schema: TSchema,
): Resolver<TForm> => {
return zodResolver(schema);
};runtime 동작은 기존 zodResolver와 동일하다.
return zodResolver(schema);차이는 compile-time에 있다.
TForm extends CheckFormSchemaMatch<TForm, TSchema>이 조건을 통해 TForm이 schema 추론 타입과 일치해야 한다는 제약을 걸었다.
사용 예시는 아래와 같다.
const form = useForm<FormType>({
resolver: strictZodResolver<typeof schema, FormType>(schema),
});5. 기대 동작
기대한 동작은 resolver를 연결하는 시점에서 타입 불일치를 확인하는 것이었다.
type FormType = {
title: string;
image: File | null;
};
const schema = z.object({
title: z.string(),
image: z.string().nullable(),
});위 코드에서 FormType의 image는 File | null이고, schema의 image는 string | null이다.
따라서 strictZodResolver를 사용하면 image 타입이 일치하지 않는다는 사실을 컴파일 단계에서 확인할 수 있을 것으로 기대했다.
이 함수는 장기적으로 사용할 공통 resolver라기보다는, 마이그레이션 초기에 기존 FormType과 schema 사이의 타입 불일치를 확인하기 위한 임시 안전장치에 가까웠다.
6. 적용 결과와 한계
에러 메시지 복잡도
strictZodResolver의 목적은 불일치한 필드를 컴파일 단계에서 확인하는 것이었다.
예상했던 에러는 아래처럼 특정 필드의 불일치 타입을 보여주는 형태였다.
Error: FormType의 'option' 타입의 범위가 더 넓습니다!하지만 실제로는 타입 비교 유틸이 useForm, Resolver, z.infer, 중첩 객체 타입과 함께 엮이면서 비슷한 에러가 반복적으로 발생했다.
Type 'Resolver<...>' is not assignable to type 'Resolver<...>'.
Types of parameters 'values' and 'values' are incompatible.
Type 'CheckFormSchemaMatch<...>' is not assignable to type '...'.
Type 'CheckTypeMatch<...>' is not assignable to type '...'.
Type instantiation is excessively deep and possibly infinite.타입 불일치를 잡아내는 것 자체는 의도한 동작이었다.
하지만 에러가 한 지점에서 깔끔하게 발생하지 않고, 타입 비교 과정의 여러 경로에서 반복적으로 드러나면서 실제 원인을 읽기 어려워졌다.
중첩 객체 추적 한계
단순한 1 depth form이라면 불일치한 필드를 비교적 명확하게 확인할 수 있었다.
type FormType = {
name: string;
option: string | null;
};
const schema = z.object({
name: z.string(),
option: z.string(),
});이 경우에는 option 필드의 타입 범위가 다르다는 것을 확인할 수 있다.
Error: FormType의 'option' 타입의 범위가 더 넓습니다!하지만 실제 form에서는 option이 단순한 primitive 값이 아니라 객체 구조를 가지는 경우가 많았다.
type FormType = {
option: {
value: string | null;
key: number;
};
};
const schema = z.object({
option: z.object({
value: z.string(),
key: z.number(),
}),
});위 코드에서 option이라는 key 자체는 양쪽에 모두 존재한다.
문제는 option 객체 내부의 value 타입이 다르다는 점이다.
option.value: string | null !== string하지만 CheckFormSchemaMatch는 1 depth 기준으로 key를 순회하는 구조였기 때문에, option 내부의 value까지 내려가서 차이를 보여주지 못했다.
결과적으로 option 객체에 타입 불일치가 있다는 사실은 알 수 있었지만, 실제 수정해야 하는 지점이 option.value라는 점은 다시 직접 확인해야 했다.
수정 지점 파악 비용
strictZodResolver는 기존 FormType과 zod schema의 추론 타입이 어긋나는 상황을 확인하기 위한 임시 안전장치였다.
하지만 실제 적용 결과, 타입 에러는 발생했지만 수정 지점을 빠르게 파악하기 어려웠다.
문제는 크게 두 가지였다.
- 타입 비교 과정에서 에러가 반복적으로 발생했다.
- 객체 구조에서는 1 depth 수준의 key까지만 의미 있게 추적되었다.
따라서 타입 비교 유틸을 계속 정교하게 만드는 대신, 타입의 기준을 schema 하나로 줄이는 방식으로 변경하기로 했다.
최종 결과: schema 기준 타입 추론
최종적으로는 strictZodResolver를 유지하지 않고, schema를 타입의 기준으로 두는 방식으로 변경했다.
schema 코드를 useForm 내부에 직접 두기보다 schema.ts 파일로 분리하고, schema에서 추론한 FormType을 함께 사용하도록 정리했다.
1. 비교
기존에는 FormType과 schema를 각각 작성했다.
// 변경 전
type FormType = {
link: string;
image: string | File | null;
};
export const schema = z.object({
link: z.string().trim().min(1, "필수값입니다."),
image: z.union([z.string(), z.instanceof(File), z.null()]),
});이 구조에서는 FormType과 schema가 계속 따로 관리된다.
따라서 둘 중 하나만 수정되면 타입 불일치가 다시 발생할 수 있다.
그래서 schema를 먼저 정의하고, FormType은 schema에서 추론하도록 바꿨다.
// 변경 후
export const schema = z.object({
link: z.string().trim().min(1, "필수값입니다."),
image: z.union([z.string(), z.instanceof(File), z.null()]),
});
export type FormType = z.infer<typeof schema>;schema를 기준으로 타입을 추론하면 FormType과 schema가 어긋날 가능성이 줄어든다.
2. 적용 방식
form에서는 schema에서 추론한 FormType을 사용한다.
const form = useForm<FormType>({
resolver: zodResolver(schema),
});이렇게 하면 useForm, resolver, defaultValue가 같은 타입 기준을 바라보게 된다.
최종적으로 form 타입과 validation schema의 기준을 하나로 모은 뒤, resolver도 일반 zodResolver로 교체했다.
3. 결과
이번 이슈를 통해 확인한 내용은 아래와 같다.
zodResolver는 validation을 수행하지만, 별도로 작성한FormType과 schema 추론 타입을 비교하지 않는다.FormType과 schema를 따로 관리하면 마이그레이션 중 타입 불일치가 발생할 수 있다.- 타입 비교를 강제하는 resolver를 만들 수는 있지만, 복잡한 form 구조에서는 에러 메시지가 과도하게 복잡해질 수 있다.
- 장기적으로는 schema를 기준으로
z.infer타입을 사용하는 방식이 더 단순했다.
4. 정리
이번 이슈의 원인은 zodResolver 자체가 아니라, FormType과 schema를 별도로 관리하던 구조에 있었다.
strictZodResolver를 통해 타입 불일치를 컴파일 단계에서 감지하려 했지만, 복잡한 form 구조에서는 에러 메시지가 과도하게 복잡해졌고 실제 수정 지점을 빠르게 파악하기 어려웠다.
따라서 최종적으로는 schema를 기준으로 FormType을 추론하도록 변경했다.
export const schema = z.object({
link: z.string().trim().min(1, "필수값입니다."),
image: z.union([z.string(), z.instanceof(File), z.null()]),
});
export type FormType = z.infer<typeof schema>;이후 useForm, resolver, defaultValue가 같은 타입 기준을 바라보게 되면서, FormType과 schema의 불일치 가능성을 줄일 수 있었다.