타입스크립트의 한계와 데이터 불일치 문제
-
타입스크립트는 코드를 작성하고 컴파일하는 시점에만 타입의 일치 여부를 검사한다.
-
실제 애플리케이션이 동작하는 런타임 환경에서는 타입스크립트의 검사 기능과 타입 정보가 모두 제거된다.
-
외부 API에서 받아오는 응답 데이터는 서버의 로직 변경이나 통신 문제로 인해 형태가 언제든 달라질 수 있다.
-
만약 서버가
name이라는 키 대신firstName을 보내더라도 타입스크립트는 이를 런타임에 인지하지 못한다. -
결국 데이터가 타입과 같다고 착각한 상태로 내부 로직을 실행하다가 없는 속성에 접근하여 프로그램이 비정상 종료된다.
방어적 프로그래밍의 한계와 Zod의 도입
-
런타임 오류를 막기 위해 과거에는 데이터가 쓰이는 모든 내부 함수에서 값이 존재하는지 일일이 확인해야 했다.
-
이렇게 내부 로직마다 데이터의 유효성을 계속 검사하는 방어적 프로그래밍은 코드를 길고 복잡하게 만든다.
-
이 문제를 해결하기 위해 애플리케이션과 외부 API가 만나는 최초의 경계에서 데이터를 한 번만 검사하는 구조가 필요해졌다.
-
Zod는 이 외부 경계에서
parse함수나safeParse함수를 이용해 데이터가 사전에 정의된 스키마와 정확히 일치하는지 실행 시점에 확인한다. -
검증을 통과하지 못한 오염된 데이터는 애플리케이션 내부로 진입하지 못하고 경계에서 즉시 차단된다.
🔗 사용 파이프라인
1. 유틸리티
이미 정의된 스키마를 조각내거나 합쳐 파생 스키마를 만들어두는 준비 단계이다.
-
스키마를 매번 처음부터 정의하면 중복되고, 베이스가 바뀔 때 파생이 어긋난다.
-
만약 베이스 하나에서 파생한 뒤 한 곳만 고친다면 나머지를 재사용할 수 있다.
-
예를 들어, 같은 도메인(예:
User)이라도 생성용은id를 빼고, 수정용은 전부 옵셔널로, 응답용은 비밀번호를 빼는 식으로 모양을 조금씩 변형해서 사용할 수 있다.
2. 입력
검증되지 않은 원시 데이터가 파이프라인에 들어오는 진입점이다.
-
TypeScript의 타입은 컴파일 시점에만 존재해서 외부 데이터에는 아무 보장도 주지 못한다.
-
API 응답·폼·환경 변수처럼 외부에서 들어오는 값은 모두 이 자리를 통과한다.
-
이 자리를 "외부 경계"로 인식해야 어디서부터 검증이 시작되어야 하는지 명확해진다.
3. 변형자(검사 전)
원시 데이터를 그대로 검증하면 실패하는 경우, 검증 직전에 표준 형태로 정리하는 단계이다.
-
외부에서 들어온 값의 타입·형태는 우리가 검증 규칙으로 기대하는 것과 어긋나는 경우가 많다.
-
환경 변수와 URL 쿼리스트링 등은 대부분 문자열로 들어온다.
-
그리고, 폼 입력에는 공백이 섞이고, 외부 API가 숫자를 문자열로 보내기도 한다.
-
형 변환과 정리를 검사 전에 한 번 거쳐야 뒤따르는 모양·범위 검증이 의미를 가진다.
4. 스키마
데이터가 어떤 모양(타입과 구조)이어야 하는지 선언하고, 들어온 값이 그 모양을 따르는지 검사하는 골격 단계이다.
-
"이 데이터의 구조는 무엇인가"를 한 곳에서 선언하는 것이
Zod의 핵심이다. -
같은 정의가 런타임 검증과 정적 타입 추론을 동시에 만들어낸다.
-
모양이 어긋난 데이터는 여기서 걸러져, 이후 단계는 안전한 모양 위에서 동작한다.
5. 조건자
모양 검사를 통과한 값에 대해 값 자체의 유효 규칙을 추가로 검사하는 단계이다.
-
타입이
string이라고 곧 이메일은 아니고,number라고 곧 양수는 아니다. -
"1~100 사이", "이메일 형식", "비밀번호에 대문자 포함" 같은 도메인 규칙이 여기에 들어간다.
-
모양 검사로 잡지 못하는 값의 유효성을 표현하는 자리이다.
6. 변경자
스키마 자체의 메타데이터나 검증 실패 시 동작 방식을 조정하는 단계이다.
-
스키마는 단순 검증을 넘어
OpenAPI변환, 폼 빌더, 문서 생성 같은 외부 도구에서도 활용된다. -
그래서 설명·예시 같은 메타데이터를 스키마에 함께 붙여둘 자리가 필요하다.
-
또한, 검증 실패를 예외로 던지지 않고 기본값으로 복구해야 할 때도 있다.
-
검증 로직 자체는 그대로 두고 스키마의 부가 동작만 조정하는 자리이다.
6. 변형자 (검사 후)
검증을 통과한 값을 후속 코드가 쓰기 편한 최종 형태로 가공하는 단계이다.
-
외부 데이터 모양과 내부에서 쓰기 좋은 모양이 같지 않을 때가 많다.
-
snake_case→camelCase매핑이나 콤마 구분 문자열 → 배열 같은 변환이 여기에 들어간다. -
검증 직후에 변환을 모아두면 이후 코드는 항상 내부 표준 모양만 다루면 된다.
7. 출력
전체 파이프라인을 실제로 실행해 타입이 보장된 결과 데이터를 꺼내는 단계이다.
-
앞 단계들은 모두 "스키마 정의"일 뿐, 호출 전에는 아무 일도 일어나지 않는다.
-
.parse()나.safeParse()를 부르는 시점에서야 실제로 검증이 돌아간다. -
실패를 예외로 받을지(
.parse()), 객체로 받을지(.safeParse()) 이 자리에서 결정한다.
1. 스키마
z 객체는 무엇인가
- 무엇인가
-
z는Zod가 제공하는 모든 스키마 생성 함수가 모여 있는 네임스페이스이다.import { z } from "zod" z.string() // 문자열 스키마 z.number() // 숫자 스키마 z.object({ ... }) // 객체 스키마
-
원시 타입 (z.string, z.number, z.boolean 등) : 가장 기본 단위 스키마
-
무엇인가
-
단일 값 하나의 타입을 검사하는 가장 기본적인 스키마이다.
-
z.string(),z.number(),z.boolean(),z.date(),z.bigint()등이 여기에 속한다.z.string().parse("hi") // ✅ "hi" 반환 (string) z.number().parse(123) // ✅ 123 반환 (number) z.boolean().parse(true) // ✅ true 반환 (boolean) z.string().parse(123) // ❌ ZodError throw (반환값 없음)
-
-
타입
-
각각
string,number,boolean,Date,bigint로 추론된다.const s = z.string() const n = z.number() const d = z.date() type S = z.infer<typeof s> // string type N = z.infer<typeof n> // number type D = z.infer<typeof d> // Date
-
-
특징
-
원시 타입은 인자 없이
z.string()만 호출해도 바로 쓸 수 있다.z.string().parse("hello") // ✅ 곧바로 사용 -
사용법: 뒤에
.min(),.max()같은 조건자나.optional()같은 변형자를 체이닝해 확장한다.z.string() // 1. 문자열이어야 함 (number, boolean 등은 ❌) .min(1) // 2. 최소 길이 1 → 빈 문자열("") 거부 .max(100) // 3. 최대 길이 100자 .optional() // 4. undefined 허용 → 타입: string | undefined
-
z.object : 객체 모양 검사
-
무엇인가
-
객체의 키와 각 값의 타입을 정의하는 스키마이다.
-
가장 자주 쓰이는 컨테이너 스키마이며, 폼·API 응답의 골격을 표현하는 데 사용된다.
const userSchema = z.object({ id: z.number(), name: z.string(), email: z.string().email(), })
-
-
타입
-
각 키의 스키마 타입을 합친 객체 타입으로 추론된다.
type User = z.infer<typeof userSchema> // { id: number; name: string; email: string }
-
-
보통 어디에 쓰나
-
폼 데이터, API 요청/응답 바디, 설정 객체 등 키-값 구조를 검사할 때 사용한다.
// API 응답 바디 검증 const apiResponse = z.object({ status: z.number(), data: z.object({ id: z.number(), name: z.string(), }), }) apiResponse.parse(await res.json())
-
-
특징
-
기본 동작은 정의되지 않은 추가 키를 조용히 제거하는 것이다.
const schema = z.object({ a: z.string() }) schema.parse({ a: "x" }) // ✅ { a: "x" } schema.parse({ a: "x", b: 1 }) // ✅ { a: "x" } ← b만 제거 schema.parse({ a: 123 }) // ❌ throws — a가 string 아님 schema.parse({ a: "x", a2: "y" }) // ✅ { a: "x" } ← 오타 a2는 사라짐 schema.parse({}) // ❌ throws — a 필드 누락 schema.parse(null) // ❌ throws — 객체 아님 schema.parse("hi") // ❌ throws — 객체 아님- 정의 안 된 필드가 있더라도, 정의된 스키마를 만족한다면 throws하지 않고, 정의되지 않은 필드를 제거한다. → 기본이 strip
- 현실의 API는 변화가 잦기 때문에 런타임 검증을 가져감과 동시에 유연성을 갖춘 구조이다.
-
추가 키를 거부하려면
z.strictObject, 유지하려면z.looseObject를 쓴다. v3의.strict()/.passthrough()도 동작은 하지만 v4에서 legacy로 권장하지 않는다.// v4 권장 z.strictObject({ a: z.string() }).parse({ a: "x", b: 1 }) // ❌ 추가 키 거부 z.looseObject({ a: z.string() }).parse({ a: "x", b: 1 }) // { a: "x", b: 1 } // 체이닝 방식도 동작은 하지만 v4에서 legacy z.object({ a: z.string() }).strict().parse({ a: "x", b: 1 }) // ❌ z.object({ a: z.string() }).passthrough().parse({ a: "x", b: 1 }) // { a: "x", b: 1 }
-
z.array : 배열 검사
-
무엇인가
-
배열의 각 원소 타입이 동일한 스키마를 따르는지 검사한다.
z.array(z.number()).parse([1, 2, 3]) // ✅ [1, 2, 3] 반환 z.array(z.number()).parse([1, "2"]) // ❌ ZodError throw // issues: [{ // code: "invalid_type", // expected: "number", // path: [1], ← 두 번째 원소(인덱스 1)에서 실패 // message: "Invalid input: expected number, received string" // }]try { z.array(z.number()).parse([1, "2", 3, "4"]) } catch (e) { if (e instanceof z.ZodError) { console.log(e.issues) // [ // { code: "invalid_type", path: [1], expected: "number", message: "..." }, // { code: "invalid_type", path: [3], expected: "number", message: "..." } // ] } }- 중요한 포인트: Zod는 첫 에러에서 멈추지 않고 배열 전체를 검사해서 모든 실패 지점을 모아서 한 번에 던진다. 그래서
issues배열에 여러 개가 들어올 수 있다.
- 중요한 포인트: Zod는 첫 에러에서 멈추지 않고 배열 전체를 검사해서 모든 실패 지점을 모아서 한 번에 던진다. 그래서
-
-
타입
-
T[]로 추론된다.const nums = z.array(z.number()) type Nums = z.infer<typeof nums> // number[]
-
-
보통 어디에 쓰나
-
동일한 모양의 항목들이 반복되는 리스트형 데이터(상품 목록, 댓글 목록 등)에 쓴다.
// 상품 목록 스키마 const productList = z.array( z.object({ id: z.number(), name: z.string(), price: z.number(), }) ) // 실제 사용 예시 const response = await fetch("/api/products") const data = await response.json() const validatedProducts = productList.parse(data) // validatedProducts 타입: { id: number; name: string; price: number }[] // 검증 실패 시 ZodError를 throw (boolean 반환 아님)
-
-
특징
-
원소 개수 제약은
.min(),.max(),.length(),.nonempty()같은 조건자로 추가한다.z.array(z.string()).min(1).max(10) // 원소 1~10개 z.array(z.string()).nonempty() // 최소 1개 (빈 배열 거부)
-
z.record : 키가 가변인 객체
-
무엇인가
-
키 이름이 미리 정해져 있지 않고, 모든 값이 같은 타입을 가지는 객체 스키마이다.
-
z.object가 "어떤 키들이 있는지" 정의한다면,z.record는 "어떤 키가 와도 값은 이 타입"이라는 식으로 정의한다. -
키 스키마와 값 스키마를 둘 다 명시해야 한다.
z.record(z.string(), z.number()) // { [key: string]: number } // { a: 1, b: 2, anything: 3 } ✅
-
-
타입
-
Record<string, T>로 추론된다.const scores = z.record(z.string(), z.number()) type Scores = z.infer<typeof scores> // Record<string, number>
-
-
보통 어디에 쓰나
-
사전(dictionary) 형태 데이터, 즉 키가 동적으로 생성되는 상황에 쓴다.
// 사용자 ID를 키로, 점수를 값으로 하는 매핑 const userScores = z.record(z.string(), z.number()) userScores.parse({ u1: 80, u2: 95, u3: 73 }) // ✅ { u1: 80, u2: 95, u3: 73 } userScores.parse({}) // ✅ 빈 객체도 OK userScores.parse({ u1: "80" }) // ❌ ZodError (값이 number 아님) userScores.parse({ u1: 80, u2: null }) // ❌ ZodError (값이 number 아님)
-
-
특징
-
첫 번째 인자로 키 스키마, 두 번째 인자로 값 스키마를 받는다.
-
enum을 키 스키마로 쓰면 모든 키가 필수로 추론되고, 파싱 시 exhaustive 검사가 일어난다.
z.record(z.enum(["a", "b"]), z.number()) // { a: number; b: number } — 모든 키가 강제됨 // optional 키가 필요하면 z.partialRecord z.partialRecord(z.enum(["a", "b"]), z.number()) // { a?: number; b?: number }
-
z.tuple : 길이와 위치별 타입이 고정된 배열
-
무엇인가
-
배열이긴 한데 위치마다 타입이 다른 경우에 사용한다.
z.tuple([z.string(), z.number(), z.boolean()]) // [string, number, boolean]
-
-
타입
-
위치별 타입을 그대로 보존한 튜플 타입으로 추론된다.
const t = z.tuple([z.string(), z.number()]) type T = z.infer<typeof t> // [string, number]
-
-
보통 어디에 쓰나
-
[lat, lng]좌표쌍,[key, value]페어 등 위치가 의미를 가지는 배열에 쓴다.const coordinate = z.tuple([z.number(), z.number()]) coordinate.parse([37.5, 127.0]) // ✅ coordinate.parse([37.5]) // ❌ (길이 부족) coordinate.parse(["37.5", 127.0]) // ❌ (타입 불일치)
-
z.enum : 정해진 값 중 하나
-
무엇인가
-
미리 정의한 문자열(또는 TypeScript enum) 목록 중 하나의 값만 허용한다.
const Role = z.enum(["admin", "user", "guest"]) Role.parse("admin") // ✅ Role.parse("other") // ❌
-
-
타입
-
리터럴 유니온 타입으로 추론된다.
type Role = z.infer<typeof Role> // "admin" | "user" | "guest"
-
-
보통 어디에 쓰나
-
권한, 상태값, 카테고리처럼 후보가 정해진 필드에 쓴다.
const postSchema = z.object({ status: z.enum(["draft", "published", "archived"]), }) postSchema.parse({ status: "draft" }) // ✅ { status: "draft" } postSchema.parse({ status: "published" }) // ✅ { status: "published" } postSchema.parse({ status: "deleted" }) // ❌ ZodError (정의되지 않은 상태값) postSchema.parse({}) // ❌ ZodError (status 누락)
- 특징
-
z.enum()은 문자열 리터럴 배열뿐 아니라 TypeScript의enum키워드로 만든 enum도 직접 받는다.enum Color { Red = "RED", Blue = "BLUE" } const ColorSchema = z.enum(Color) ColorSchema.parse("RED") // ✅ ColorSchema.parse("GREEN") // ❌
-
z.literal : 정확히 그 값 하나
-
무엇인가
-
특정 리터럴 값 하나만 통과시키는 스키마이다.
z.literal("text").parse("text") // ✅ "text" 반환 (입력값 그대로) z.literal("text").parse("other") // ❌ ZodError throw // issues: [{ // code: "invalid_value", // values: ["text"], // path: [], // message: 'Invalid input: expected "text"' // }]
-
-
타입
-
해당 리터럴 타입 하나로 추론된다.
const tag = z.literal("text") type Tag = z.infer<typeof tag> // "text" (리터럴)
-
-
보통 어디에 쓰나
-
판별자(discriminator) 필드, 즉 객체가 어떤 종류인지 구분할 때 자주 쓴다.
const text = z.object({ type: z.literal("text"), content: z.string() }) const image = z.object({ type: z.literal("image"), url: z.string() }) // 각 객체가 어느 종류인지 type 필드로 구별 가능 text.parse({ type: "text", content: "hi" }) // ✅ { type: "text", content: "hi" } text.parse({ type: "image", content: "hi" }) // ❌ type이 "text"가 아님 image.parse({ type: "image", url: "https://x.com/a.png" }) // ✅ { type: "image", url: "https://x.com/a.png" } image.parse({ type: "video", url: "https://x.com/a.png" }) // ❌ "image"만 허용
-
z.union / z.discriminatedUnion : 여러 스키마 중 하나
-
무엇인가
-
여러 후보 스키마 중 하나에라도 통과하면 성공인 합집합 스키마이다.
const u = z.union([z.string(), z.number()]) u.parse("hello") // "hello" → "hello" u.parse(42) // 42 → 42 u.parse("") // "" → "" (string 통과) u.parse(0) // 0 → 0 (number 통과) u.parse(true) // true → ❌ ZodError (어느 분기에도 매칭 X) u.parse(null) // null → ❌ ZodError u.parse(undefined) // undef → ❌ ZodError // 실패 시 에러: 모든 분기의 실패 사유가 누적되어 모호함 // issues: [{ // code: "invalid_union", // errors: [ // [{ ... }], // z.string() 시도 실패 사유들 // [{ ... }], // z.number() 시도 실패 사유들 // ] // }]
-
-
타입
-
각 후보 스키마의 유니온으로 추론된다.
type U = z.infer<typeof u> // string | number
-
-
특징
-
z.union은 모든 후보를 차례대로 시도하므로 후보가 많으면 느리고 에러 메시지도 모호하다.// 객체 유니온을 z.union으로 하면 어디서 실패했는지 명확하지 않다 z.union([ z.object({ type: z.literal("text"), content: z.string() }), z.object({ type: z.literal("image"), url: z.string() }), ]).parse({ type: "text", content: 123 }) // → "전체가 어디 매칭도 안 됨" 정도의 에러 -
객체 유니온이라면
z.discriminatedUnion(키, [스키마들])을 쓰는 것이 빠르고 에러 메시지도 명확하다.z.discriminatedUnion("type", [ z.object({ type: z.literal("text"), content: z.string() }), z.object({ type: z.literal("image"), url: z.string() }), ]).parse({ type: "text", content: 123 }) // → "text 분기의 content 필드 검증 실패" 처럼 정확한 위치 안내
-
z.lazy : 재귀 스키마 (스키마 안에서 자기 자신을 다시 써야 할 때)
-
무엇인가
-
예를 들어, 댓글의 경우 답글이 연속적으로 달릴 수 있다.
type Comment = { text: string replies: Comment[] // ← 자기 자신 타입의 배열 } -
이 구조를 zod로 옮기다보면 문제가 생긴다.
const Comment = // ← 이 줄이 끝나야 Comment에 값이 들어감 z.object({ // ← 근데 이걸 평가해야 그 값이 만들어짐 text: z.string(), replies: z.array(Comment) // ← 평가 중에 Comment를 읽으려고 함 // 근데 Comment는 아직 값이 없음! }) -
자바스크립트 실행 순서를 따라가보면, 변수에 대입하기 전에 읽으려 하기 때문이다.
-
자바스크립트에서 “나중에 실행”하는 방법은 함수로 감싸면 된다.
// 함수 정의: 본문은 아직 실행 안 됨 const later = () => Comment // 이 줄 시점엔 Comment가 아직 없어도 OK // 누군가 later()를 호출하는 그 시점에야 Comment를 읽음 -
이렇게 하면 함수 본문은 정의될 때가 아니라, 호출될 때 실행된다.
-
z.lazy(fn)이 하는 일은 스키마 정의를 함수로 감싸서, 실제 파싱이 일어날 때까지 평가를 미룬다.const Comment: z.ZodType<CommentType> = z.lazy(() => z.object({ text: z.string(), replies: z.array(Comment) // ✅ 함수 안이라 지금 실행 안 됨 // parse() 호출될 때 그제서야 평가 // 그때는 Comment가 이미 정의 완료 }) )
-
-
주의사항
-
재귀 스키마는 TypeScript가 타입을 자동 추론하지 못하므로
z.ZodType<...>같은 타입 어노테이션을 직접 달아 줘야 한다.// ❌ 어노테이션 없으면 추론 실패 const bad = z.lazy(() => z.object({ children: z.array(bad), // 'bad' 참조 시 타입 오류 })) // ✅ 명시적 타입 선언 type Tree = { children: Tree[] } const tree: z.ZodType<Tree> = z.lazy(() => z.object({ children: z.array(tree), }))
-
z.coerce.* : 검사 전에 강제 형변환
-
무엇인가
-
검증에 들어가기 전에 값을 원하는 타입으로 강제로 변환하는 스키마군이다.
// z.coerce.number() z.coerce.number().parse("3000") // "3000" → 3000 z.coerce.number().parse("3.14") // "3.14" → 3.14 z.coerce.number().parse("") // "" → 0 ⚠️ 조심 z.coerce.number().parse(null) // null → 0 ⚠️ 조심 z.coerce.number().parse("abc") // "abc" → ❌ ZodError // z.coerce.boolean() ← Boolean() 사용, 함정 많음 z.coerce.boolean().parse("true") // "true" → true z.coerce.boolean().parse("false") // "false" → true ⚠️ 조심 z.coerce.boolean().parse("0") // "0" → true ⚠️ 조심 z.coerce.boolean().parse("") // "" → false z.coerce.boolean().parse(undefined)// undef → false // z.coerce.string() z.coerce.string().parse(123) // 123 → "123" z.coerce.string().parse(true) // true → "true" z.coerce.string().parse(null) // null → "null" ⚠️ 조심 // z.coerce.date() z.coerce.date().parse("2026-05-23") // string → Date(2026-05-23) z.coerce.date().parse(1716422400000) // number → Date(timestamp) z.coerce.date().parse("invalid") // ❌ ZodError // z.coerce.bigint() z.coerce.bigint().parse("100") // "100" → 100n z.coerce.bigint().parse(100) // 100 → 100n z.coerce.bigint().parse("1.5") // ❌ ZodError (소수점 불가)
-
-
보통 어디에 쓰나
-
폼 입력값(항상 문자열), URL 쿼리스트링, 환경 변수처럼 들어올 때 무조건 문자열인 값을 숫자나 날짜 등으로 바꿔야 할 때 쓴다.
// 환경 변수는 process.env에서 항상 string const envSchema = z.object({ PORT: z.coerce.number(), // "3000" → 3000 DEBUG: z.coerce.boolean(), // "true" → true (주의) }) envSchema.parse(process.env)
-
-
주의사항
-
z.coerce.boolean()은 내부적으로Boolean(value)을 호출하므로"false"문자열도true로 변환된다.z.coerce.boolean().parse("false") // true ⚠️ 의도와 다름 z.coerce.boolean().parse("0") // true ⚠️ z.coerce.boolean().parse("") // false -
이런 케이스에는 직접
.transform()이나.preprocess()로 규칙을 명시하는 편이 안전하다.z.preprocess( (v) => v === "true", z.boolean() ).parse("false") // false ✅
-
z.any / z.unknown / z.never / z.void : 특수 스키마
-
무엇인가
-
검증을 거치지 않거나(
z.any,z.unknown), 어떤 값도 통과시키지 않는(z.never) 특수 스키마들이다.// z.any() — 무엇이든 통과, 타입은 any z.any().parse("hello") // "hello" → "hello" (type: any) z.any().parse(42) // 42 → 42 (type: any) z.any().parse({ x: 1 }) // {x:1} → {x:1} (type: any) z.any().parse(null) // null → null (type: any) z.any().parse(undefined) // undef → undefined (type: any) // z.unknown() — 무엇이든 통과, 타입은 unknown (좁히기 전엔 못 씀) z.unknown().parse("hello") // "hello" → "hello" (type: unknown) z.unknown().parse(42) // 42 → 42 (type: unknown) z.unknown().parse({ x: 1 }) // {x:1} → {x:1} (type: unknown) // z.never() — 무엇이든 실패 (절대 통과 못 함) z.never().parse("x") // "x" → ❌ ZodError z.never().parse(undefined) // undef → ❌ ZodError z.never().parse(null) // null → ❌ ZodError
-
-
특징
-
z.unknown쪽이 권장되는데, 사용 시점에 다시 좁혀야 하므로 타입 안전성이 더 높다.const data: unknown = z.unknown().parse(await res.json()) // data.foo ❌ 컴파일 에러 → 좁히기 강제됨 if (typeof data === "object" && data !== null && "foo" in data) { // 좁힌 후 안전하게 사용 }
-
2. 조건자
.min(n) / .max(n) / .length(n) : 길이·크기 제약
-
무엇인가
-
문자열의 글자 수, 배열의 원소 개수, 숫자의 값 범위를 제한한다.
// z.string().min(1).max(20) z.string().min(1).max(20).parse("hi") // → "hi" z.string().min(1).max(20).parse("a") // → "a" z.string().min(1).max(20).parse("") // ❌ ZodError (0글자, min 위반) z.string().min(1).max(20).parse("a".repeat(21)) // ❌ ZodError (21글자, max 위반) z.string().min(1).max(20).parse(" ") // → " " (공백도 글자로 셈) // z.array(z.string()).min(1) z.array(z.string()).min(1).parse(["a"]) // → ["a"] z.array(z.string()).min(1).parse([]) // ❌ ZodError (빈 배열, min 위반) z.array(z.string()).min(1).parse([""]) // → [""] (빈 문자열도 원소 1개) // z.number().min(0).max(100) z.number().min(0).max(100).parse(50) // → 50 z.number().min(0).max(100).parse(0) // → 0 (경계 포함) z.number().min(0).max(100).parse(100) // → 100 (경계 포함) z.number().min(0).max(100).parse(-1) // ❌ ZodError (음수, min 위반) z.number().min(0).max(100).parse(101) // ❌ ZodError (100 초과, max 위반) z.number().min(0).max(100).parse(3.14) // → 3.14 (소수도 통과) z.number().min(0).max(100).parse(NaN) // ❌ ZodError (NaN은 숫자 아님)
-
-
특징
-
.length(n)은 정확히 그 값일 때만 통과시킨다.z.string().length(10).parse("abcdefghij") // ✅ 정확히 10 z.string().length(10).parse("abc") // ❌ -
두 번째 인자로 에러 시 표시할 안내 문구를 지정할 수 있다.
z.string().min(1, "필수 입력입니다").parse("") // ZodError: 필수 입력입니다
-
.email / .url / .uuid / .regex : 문자열 형식 검사
-
무엇인가
-
문자열이 특정 형식(이메일, URL, UUID, 정규식)을 따르는지 검사한다.
// z.email() z.email().parse("a@b.com") // → "a@b.com" z.email().parse("user.name@x.co.kr") // → "user.name@x.co.kr" z.email().parse("not-an-email") // ❌ ZodError (@ 없음) z.email().parse("a@") // ❌ ZodError (도메인 없음) z.email().parse("") // ❌ ZodError (빈 문자열) // z.url() z.url().parse("https://x.com") // → "https://x.com" z.url().parse("http://localhost:3000") // → "http://localhost:3000" z.url().parse("ftp://files.x.com") // → "ftp://files.x.com" (프로토콜 무관) z.url().parse("x.com") // ❌ ZodError (프로토콜 없음) z.url().parse("not a url") // ❌ ZodError // z.uuid() z.uuid().parse(crypto.randomUUID()) // → "550e8400-e29b-..." z.uuid().parse("abc") // ❌ ZodError (UUID 형식 아님) z.uuid().parse("550e8400-e29b") // ❌ ZodError (길이 부족) // z.string().regex(...) — regex는 string 메서드로 유지됨 z.string().regex(/^[A-Z]+$/).parse("ABC") // → "ABC" z.string().regex(/^[A-Z]+$/).parse("abc") // ❌ ZodError (소문자 포함) z.string().regex(/^[A-Z]+$/).parse("AB1") // ❌ ZodError (숫자 포함) z.string().regex(/^[A-Z]+$/).parse("") // ❌ ZodError (빈 문자열, + 위반) // 체이닝 방식은 deprecated z.string().email().parse("a@b.com") // ⚠️ deprecated — z.email() 사용 권장
-
-
보통 어디에 쓰나
-
폼 입력값을 정규식으로 직접 짜기 전에
Zod가 제공하는 표준 형식 검사를 먼저 활용한다.const signupSchema = z.object({ email: z.email(), website: z.url().optional(), inviteCode: z.uuid().optional(), }) signupSchema.parse({ email: "a@b.com" }) // ✅ website, inviteCode 생략 OK signupSchema.parse({ email: "a@b.com", website: "https://x.com" }) // ✅ signupSchema.parse({ email: "not-email" }) // ❌ email 형식 X signupSchema.parse({ email: "a@b.com", website: "x.com" }) // ❌ url 프로토콜 X signupSchema.parse({ email: "a@b.com", inviteCode: "abc" }) // ❌ UUID 형식 X
-
.startsWith / .endsWith / .includes : 부분 문자열 검사
-
무엇인가
-
문자열이 특정 부분 문자열로 시작하거나 끝나거나 포함하는지 검사한다.
// z.string().startsWith(...) z.string().startsWith("https://").parse("https://x.com") // → "https://x.com" z.string().startsWith("https://").parse("http://x.com") // ❌ ZodError (http로 시작) // z.string().endsWith(...) z.string().endsWith(".com").parse("a.com") // → "a.com" z.string().endsWith(".com").parse("a.co.kr") // ❌ ZodError (.kr로 끝남) // z.string().includes(...) z.string().includes("@").parse("a@b") // → "a@b" z.string().includes("@").parse("ab") // ❌ ZodError (@ 없음)
-
-
보통 어디에 쓰나
-
프로토콜 강제, 도메인 검증, 키워드 포함 검사 등에 쓴다.
const imageUrl = z.string().url().startsWith("https://") // HTTP는 거부, HTTPS만 허용 imageUrl.parse("https://x.com/a.png") // ✅ "https://x.com/a.png" imageUrl.parse("http://x.com/a.png") // ❌ http는 거부 imageUrl.parse("https://") // ❌ URL 형식 X imageUrl.parse("x.com/a.png") // ❌ URL 형식 X
-
.gt / .gte / .lt / .lte / .int / .positive / .multipleOf : 숫자 제약
- 무엇인가
-
숫자의 범위와 형태를 세밀하게 제약하는 조건자들이다.
// z.number().gt(0) — 0 초과 (> 0) z.number().gt(0).parse(1) // → 1 z.number().gt(0).parse(0.001) // → 0.001 z.number().gt(0).parse(0) // ❌ ZodError (0은 초과 아님) z.number().gt(0).parse(-1) // ❌ ZodError (음수) // z.number().gte(0) — 0 이상 (>= 0) z.number().gte(0).parse(0) // → 0 (경계 포함) z.number().gte(0).parse(1) // → 1 z.number().gte(0).parse(-1) // ❌ ZodError (음수) // z.number().lt(10) — 10 미만 (< 10) z.number().lt(10).parse(9.99) // → 9.99 z.number().lt(10).parse(10) // ❌ ZodError (10은 미만 아님) // z.number().lte(10) — 10 이하 (<= 10) z.number().lte(10).parse(10) // → 10 (경계 포함) z.number().lte(10).parse(11) // ❌ ZodError // z.number().int() — 정수만 z.number().int().parse(42) // → 42 z.number().int().parse(-7) // → -7 z.number().int().parse(0) // → 0 z.number().int().parse(3.14) // ❌ ZodError (소수) // z.number().positive() — > 0 (.gt(0)과 동일) z.number().positive().parse(1) // → 1 z.number().positive().parse(0) // ❌ ZodError (0 불포함) z.number().positive().parse(-1) // ❌ ZodError // z.number().multipleOf(5) — 5의 배수 z.number().multipleOf(5).parse(0) // → 0 z.number().multipleOf(5).parse(5) // → 5 z.number().multipleOf(5).parse(-10) // → -10 (음수도 배수) z.number().multipleOf(5).parse(7) // ❌ ZodError (배수 아님) z.number().multipleOf(5).parse(2.5) // ❌ ZodError- positive는 0보다 큰 수만 통과시킨다.
-
- 특징
-
.min/.max와.gte/.lte는 같은 의미이며 가독성에 맞춰 골라 쓰면 된다.z.number().min(0) // 0 이상 z.number().gte(0) // 0 이상 (동일) -
여러 조건자를 체이닝해 복합 제약을 표현할 수 있다.
z.number().int().positive().multipleOf(5) // 5, 10, 15, ... 만 통과
-
.refine(fn) : 커스텀 검증
-
무엇인가
-
내장 조건자로 표현 불가한 규칙을 함수로 직접 작성하는 메서드이다.
// 1. 앞뒤 공백 없는 문자열 const noPadded = z.string().refine( (v) => v === v.trim(), { error: "앞뒤 공백이 없어야 합니다" } // ZodError 에러 객체 안에 있다. ) noPadded.parse("hello") // → "hello" noPadded.parse("hel lo") // → "hel lo" (중간 공백은 OK) noPadded.parse("") // → "" ⚠️ "" === "".trim()이라 통과 noPadded.parse(" hello") // ❌ ZodError (앞 공백) noPadded.parse("hello ") // ❌ ZodError (뒤 공백) // 2. 짝수만 허용 const evenOnly = z.number().refine( (n) => n % 2 === 0, { error: "짝수여야 합니다" } ) evenOnly.parse(2) // → 2 evenOnly.parse(0) // → 0 (0도 짝수) evenOnly.parse(-4) // → -4 (음수 짝수도 통과) evenOnly.parse(3) // ❌ ZodError (홀수) evenOnly.parse(2.5) // ❌ ZodError (정수가 아님 → 짝수도 아님) // 3. 비밀번호 — 영문과 숫자 모두 포함 const password = z.string().min(8).refine( (v) => /[0-9]/.test(v) && /[a-zA-Z]/.test(v), { error: "영문과 숫자를 모두 포함해야 합니다" } ) password.parse("abc12345") // → "abc12345" password.parse("abcdefgh") // ❌ ZodError (숫자 없음) password.parse("12345678") // ❌ ZodError (영문 없음) password.parse("ab12") // ❌ ZodError (8자 미만, refine 도달 전 실패)
-
-
타입
-
입력 타입은 바꾸지 않고, 통과 여부만 결정한다.
const trimmed = z.string().refine((v) => v === v.trim()) type T = z.infer<typeof trimmed> // string (변화 없음)
-
-
보통 어디에 쓰나
-
비밀번호 강도, 두 필드 값 일치 여부 등 표준 조건자로 못 표현하는 비즈니스 규칙에 쓴다.
const strongPassword = z.string().refine( (v) => /[A-Z]/.test(v) && /[0-9]/.test(v), { error: "대문자와 숫자를 포함해야 합니다" } )
-
-
특징
-
객체에 붙이면 두 필드를 비교하는 검사도 가능하다.
z.object({ pw: z.string(), pw2: z.string() }) .refine((d) => d.pw === d.pw2, { error: "비밀번호가 일치하지 않습니다", path: ["pw2"], // 이 에러가 어느 필드에 속하는 에러인지 알려주는 옵션이다. })path를 넣어야, 원하는 필드에 에러 메시지를 넣을 수 있다.
-
error에 함수도 넣을 수 있다.z.number().refine( (n) => n % 2 === 0, { error: (issue) => `${issue.input}은(는) 짝수가 아닙니다` } ) // parse(3) 실패 시 메시지: "3은(는) 짝수가 아닙니다"
-
.superRefine(fn) : 여러 이슈를 한 번에 추가
-
무엇인가
-
.refine이 단일 통과/실패만 다루는 반면,.superRefine은 여러 에러를 한 번에 추가할 수 있다.// ctx: Zod가 주는 도구 객체 const schema = z.array(z.string()).superRefine((arr, ctx) => { if (arr.length < 2) { ctx.addIssue({ code: "custom", message: "2개 이상 필요" }) } if (new Set(arr).size !== arr.length) { ctx.addIssue({ code: "custom", message: "중복 불가" }) } }) // issue code는 string 리터럴 ("custom", "too_big" 등) 사용 schema.parse(["a", "b"]) // → ["a", "b"] schema.parse(["a", "b", "c"]) // → ["a", "b", "c"] schema.parse([]) // ❌ ZodError (0개, 2개 이상 필요) schema.parse(["a"]) // ❌ ZodError (1개, 2개 이상 필요) schema.parse(["a", "a"]) // ❌ ZodError (중복 불가) schema.parse(["a", "b", "a"]) // ❌ ZodError (중복 불가) schema.parse(["a", 1]) // ❌ ZodError (1이 string 아님, superRefine 도달 전 실패)
-
-
보통 어디에 쓰나
-
한 필드에서 여러 검사를 동시에 돌려 모든 위반을 한꺼번에 보여주고 싶을 때 사용한다.
const password = z.string().superRefine((v, ctx) => { if (v.length < 8) ctx.addIssue({ code: "custom", message: "8자 이상" }) if (!/[A-Z]/.test(v)) ctx.addIssue({ code: "custom", message: "대문자 필요" }) if (!/[0-9]/.test(v)) ctx.addIssue({ code: "custom", message: "숫자 필요" }) }) // "abc" 입력 시 → 8자 이상, 대문자 필요, 숫자 필요 모두 한 번에 표시
-
3. 변경자
.describe(text) : 설명 메타 부여
-
무엇인가
-
스키마에 사람이 읽을 설명을 붙이는 메서드이다.
const emailSchema = z.string().describe("사용자의 이메일") // 파싱 동작은 동일 (describe는 메타데이터만 추가) emailSchema.parse("a@b.com") // → "a@b.com" // 붙은 설명 읽기 emailSchema.description // → "사용자의 이메일" // JSON Schema 변환 시 description 필드로 반영됨 z.toJSONSchema(emailSchema) // → { type: "string", description: "사용자의 이메일" }
-
-
보통 어디에 쓰나
-
폼 자동 생성, OpenAPI 스키마 변환, 에러 메시지 가공 등 외부 도구에서 스키마 메타를 읽을 때 활용된다.
const userSchema = z.object({ email: z.string().email().describe("사용자 이메일"), age: z.number().int().describe("나이 (만)"), }) // OpenAPI 변환 라이브러리가 description 필드로 활용 // 폼 빌더가 라벨로 활용
-
.brand<T>() : 명목적 타입 부여
-
무엇인가
-
구조가 같아도 의미가 다른 타입을 구분하기 위해 타입에 "브랜드"라는 표시를 붙이는 메서드이다.
const UserId = z.string().brand<"UserId">() const PostId = z.string().brand<"PostId">() // 런타임 동작은 그냥 string const u = UserId.parse("u_123") // → "u_123" const p = PostId.parse("p_456") // → "p_456" // TS 타입은 서로 다름 (nominal typing) type U = z.infer<typeof UserId> // string & z.BRAND<"UserId"> type P = z.infer<typeof PostId> // string & z.BRAND<"PostId"> function getUser(id: U) { /* ... */ } getUser(u) // ✅ getUser(p) // ❌ TS 에러 (PostId는 UserId 아님) getUser("u_123") // ❌ TS 에러 (parse 거치지 않은 string은 UserId 아님)
-
-
보통 어디에 쓰나
-
ID 종류처럼 단순
string으로는 충돌이 우려되는 값들을 컴파일 타임에 분리할 때 사용한다.type UserId = z.infer<typeof UserId> type PostId = z.infer<typeof PostId> function fetchUser(id: UserId) { /* ... */ } const postId = PostId.parse("p1") fetchUser(postId) // ❌ 컴파일 에러 (PostId를 UserId 자리에 못 씀)
-
.readonly() : 읽기 전용 추론
-
무엇인가
-
추론되는 타입을
readonly로 만들어 변경 불가하게 표시한다.const schema = z.object({ name: z.string() }).readonly() // 런타임 동작은 동일 (값 그대로 통과) const user = schema.parse({ name: "Alice" }) // → { name: "Alice" } // TS 타입에 readonly가 붙음 type User = z.infer<typeof schema> // { readonly name: string } user.name // ✅ 읽기 OK user.name = "Bob" // ❌ TS 에러 (readonly 속성) // 배열도 ReadonlyArray로 추론됨 const tags = z.array(z.string()).readonly() type Tags = z.infer<typeof tags> // readonly string[]
-
-
보통 어디에 쓰나
-
검증 결과를 의도치 않게 수정하지 못하도록 막을 때 쓴다.
const config = z.object({ host: z.string(), port: z.number() }).readonly() const parsed = config.parse({ host: "localhost", port: 3000 }) // parsed: Readonly<{ host: string; port: number }> parsed.host // ✅ "localhost" 읽기 OK parsed.port = 80 // ❌ 컴파일 에러 (readonly) parsed.host = "new.com" // ❌ 컴파일 에러 (readonly)
-
.catch(default) : 실패 시 기본값으로 대체 (에러를 던지지 않는다)
-
무엇인가
-
검증이 실패해도 에러를 던지지 않고, 지정한 기본값으로 대체하도록 만든다.
// z.number().catch(0) — 파싱 실패 시 무조건 0 반환 (에러 없음) z.number().catch(0).parse(42) // → 42 z.number().catch(0).parse(-1.5) // → -1.5 z.number().catch(0).parse(0) // → 0 z.number().catch(0).parse("not a number") // → 0 (string 거부 → fallback) z.number().catch(0).parse(null) // → 0 (fallback) z.number().catch(0).parse(undefined) // → 0 (fallback) z.number().catch(0).parse(NaN) // → 0 (NaN 거부 → fallback) z.number().catch(0).parse({}) // → 0 (fallback)
-
-
.default()와의 차이-
.default(v)는 입력이undefined일 때만 기본값을 쓴다.z.number().default(0).parse(undefined) // 0 z.number().default(0).parse("x") // ❌ 에러 (undefined 아님) -
.catch(v)는 어떤 종류의 검증 실패든 모두 잡아 기본값으로 돌린다.z.number().catch(0).parse(undefined) // 0 z.number().catch(0).parse("x") // 0 (에러 흡수) z.number().catch(0).parse(null) // 0
-
-
주의사항
-
모든 에러를 삼키므로 사용자 입력 검증에는 잘 어울리지 않는다.
// ❌ 폼 검증에 쓰면 잘못된 입력도 그냥 통과 z.string().email().catch("noreply@x.com") -
비필수 값(예: 설정의 옵션 필드)에 안전망 용도로만 쓴다.
// ✅ 설정 파일 기본값 안전망 const config = z.object({ theme: z.enum(["light", "dark"]).catch("light"), pageSize: z.number().int().positive().catch(20), })
-
4. 변형자
.optional() : 있어도 되고 없어도 되고
-
무엇인가
-
해당 필드가 undefined 여도 통과시키는 변형자
const schema = z.object({ title: z.string(), // 필수 memo: z.string().optional(), // 있어도 되고 없어도 됨 }) // 추론 타입: { title: string; memo?: string | undefined } schema.parse({ title: "a" }) // → { title: "a" } (memo 생략 OK) schema.parse({ title: "a", memo: "b" }) // → { title: "a", memo: "b" } schema.parse({ title: "a", memo: undefined }) // → { title: "a", memo: undefined } schema.parse({ title: "a", memo: "" }) // → { title: "a", memo: "" } (빈 문자열도 string) schema.parse({ title: "a", memo: null }) // ❌ ZodError (null은 optional 아님) schema.parse({ memo: "b" }) // ❌ ZodError (title 누락) schema.parse({}) // ❌ ZodError (title 누락)
-
-
타입
-
T | undefinedconst schema = z.object({ title: z.string(), // 타입: string memo: z.string().optional(), // 타입: string | undefined }) type Values = z.infer<typeof schema> // { // title: string // memo: string | undefined ← .optional() 때문에 undefined 가 합쳐짐 // }
-
-
보통 어디에 붙이나
-
사용자가 비워둘 수 있는 선택 입력 폼 필드 (메모, 부가 옵션 등)
z.object({ title: z.string(), memo: z.string().optional(), // 사용자가 비워두면 undefined }) -
다른 필드 값에 따라 조건부로만 존재하는 필드
z.object({ type: z.enum(["text", "image"]), imageUrl: z.string().optional(), // type === "image" 일 때만 채워짐 }) -
전체 키를 다 보내지 않아도 되는 부분 업데이트(PATCH) 요청 바디
const patchSchema = z.object({ title: z.string().optional(), memo: z.string().optional(), }) // { title: "new" } ✅ (memo 만 그대로 두기) -
백엔드가 특정 조건에서만 내려주는 API 응답의 누락 가능 필드
const responseSchema = z.object({ id: z.number(), deletedAt: z.string().optional(), // 삭제된 항목에만 존재 })
-
-
주의사항
-
null은 통과시키지 않는다. 백엔드가null을 내려주는 필드라면.nullable()또는.nullish()를 써야 한다.z.string().optional().parse(null) // ❌ 에러 z.string().nullable().parse(null) // ✅ z.string().nullish().parse(null) // ✅ (undefined 도 통과) -
.optional()뒤에는.min(),.max()같은 기본 검증 메서드를 더 못 붙인다. 검증은.optional()앞에서 끝낸다.z.string().min(1).optional() // ✅ z.string().optional().min(1) // ❌ 컴파일 에러
-
-
특징
-
.optional()위치는 체이닝 맨 뒤에 두는 게 일반적이다. 앞쪽 검증을 다 거친 뒤 "이게 undefined 면 통과" 로 읽혀서 의도가 명확해진다.z.string().min(1).max(100).optional() // 권장
-
.nullable() : null 도 통과시킨다
-
무엇인가
-
해당 필드가 null 이어도 통과시키는 변형자
const schema = z.string().nullable() // 추론 타입: string | null schema.parse("abc") // → "abc" schema.parse("") // → "" (빈 문자열도 string) schema.parse(null) // → null schema.parse(undefined) // ❌ ZodError (undefined는 nullable 아님) schema.parse(0) // ❌ ZodError (number는 string 아님)
-
-
타입
-
T | nullconst schema = z.string().nullable() type V = z.infer<typeof schema> // string | null
-
-
보통 어디에 붙이나
-
백엔드 API가 명시적으로
null을 내려주는 필드에 붙인다.const responseSchema = z.object({ deletedAt: z.string().nullable(), // 삭제 안 됐으면 null }) responseSchema.parse({ deletedAt: null }) // ✅ { deletedAt: null } responseSchema.parse({ deletedAt: "2026-01-01" }) // ✅ { deletedAt: "2026-01-01" } responseSchema.parse({ deletedAt: undefined }) // ❌ ZodError (undefined 불가) responseSchema.parse({}) // ❌ ZodError (필수 키 누락)
-
-
주의사항
-
undefined는 통과시키지 않는다.z.string().nullable().parse(null) // ✅ z.string().nullable().parse(undefined) // ❌ -
null과undefined를 모두 허용해야 한다면.nullish()를 쓴다.z.string().nullish().parse(null) // ✅ z.string().nullish().parse(undefined) // ✅
-
.nullish() : null 과 undefined 모두 통과
-
무엇인가
-
.optional()과.nullable()을 합친 단축 변형자이다.const schema = z.string().nullish() // 추론 타입: string | null | undefined schema.parse("abc") // → "abc" schema.parse("") // → "" (빈 문자열도 string) schema.parse(null) // → null schema.parse(undefined) // → undefined schema.parse(0) // ❌ ZodError (number는 string 아님)
-
-
타입
-
T | null | undefinedconst schema = z.string().nullish() type V = z.infer<typeof schema> // string | null | undefined
-
-
보통 어디에 붙이나
-
백엔드가 동일한 필드를 어떨 땐
null로, 어떨 땐 누락된 채로 내려주는 느슨한 API 응답 처리에 쓴다.const responseSchema = z.object({ // 응답에 따라 deletedAt: null, 혹은 키 자체가 없음 deletedAt: z.string().nullish(), }) responseSchema.parse({ deletedAt: null }) // ✅ { deletedAt: null } responseSchema.parse({ deletedAt: undefined }) // ✅ { deletedAt: undefined } responseSchema.parse({}) // ✅ {} (키 자체가 없어도 OK) responseSchema.parse({ deletedAt: "2026-01-01" }) // ✅ { deletedAt: "2026-01-01" } responseSchema.parse({ deletedAt: 0 }) // ❌ ZodError (string 아님)
-
.default(v) : 비어 있으면 기본값을 채워준다
-
무엇인가
-
입력이
undefined일 때 지정한 기본값으로 자동으로 채워주는 변형자이다.const schema = z.string().default("guest") // 입력 타입: string | undefined // 출력 타입: string schema.parse("alice") // → "alice" schema.parse("") // → "" (빈 문자열도 string, default 안 됨) schema.parse(undefined) // → "guest" (undefined만 default로 대체) schema.parse(null) // ❌ ZodError (null은 default 트리거 안 함)
-
-
타입
-
입력 타입은
T | undefined이지만, 출력 타입은T로 추론된다.const schema = z.object({ role: z.string().default("user") }) type In = z.input<typeof schema> // { role?: string | undefined } type Out = z.output<typeof schema> // { role: string }
-
-
보통 어디에 붙이나
-
사용자가 안 채워도 합리적인 기본값이 정해진 필드(권한 기본값, 페이지 사이즈 기본값 등)에 붙인다.
z.object({ pageSize: z.number().default(20), sort: z.enum(["asc", "desc"]).default("asc"), }) -
기본값이 동적이어야 한다면 함수 형태로도 넘길 수 있다.
z.object({ createdAt: z.date().default(() => new Date()), })
-
-
주의사항
-
null이 들어오면 기본값으로 안 바뀌고 그냥 검증 실패가 난다.z.string().default("guest").parse(null) // ❌ 에러 z.string().default("guest").parse(undefined) // "guest" ✅ -
null도 기본값으로 바꾸려면.transform()이나.preprocess()로 직접 처리한다.z.preprocess( (v) => v ?? undefined, // null → undefined 로 정규화 z.string().default("guest") ).parse(null) // "guest" ✅
-
-
특징
-
출력 타입이
T(non-optional)이 되므로 후속 로직에서undefined처리 코드를 안 써도 된다.const schema = z.object({ role: z.string().default("user") }) const { role } = schema.parse({}) role.toUpperCase() // ✅ role은 string으로 확정됨
-
.transform(fn) : 검증 후에 값을 변형
-
무엇인가
-
스키마 검증을 통과한 값을 함수로 한 번 더 가공해 새 값으로 만든다.
const schema = z.string().transform((v) => v.trim().toLowerCase()) // 입력 타입: string // 출력 타입: string (변환 결과) schema.parse(" Alice ") // → "alice" schema.parse("HELLO") // → "hello" schema.parse(" ") // → "" (전부 공백 → trim 후 빈 문자열) schema.parse("") // → "" schema.parse(123) // ❌ ZodError (string 아님, transform 도달 전 실패) schema.parse(null) // ❌ ZodError (string 아님)
-
-
타입
-
입력 타입과 출력 타입이 달라질 수 있다.
const schema = z.string().transform((v) => v.length) // 입력 타입(z.input): string // 출력 타입(z.output): number ← transform으로 타입이 바뀜 // z.infer는 output과 동일 → number schema.parse("hello") // → 5 schema.parse("") // → 0 schema.parse(" abc ") // → 5 (공백도 길이에 포함) schema.parse("한글") // → 2 schema.parse(123) // ❌ ZodError (string 아님, transform 도달 전 실패) schema.parse(null) // ❌ ZodError
-
-
보통 어디에 붙이나
-
입력 정규화(
trim,toLowerCase)에 쓴다.z.string().transform((v) => v.trim()) -
문자열을 다른 타입으로 변환할 때 쓴다.
z.string().transform((v) => v.split(",").map(Number)) // "1,2,3" → [1, 2, 3] -
외부 데이터 모양을 내부 모델로 매핑할 때 쓴다.
z.object({ first_name: z.string(), last_name: z.string() }) .transform((d) => ({ firstName: d.first_name, lastName: d.last_name }))
-
-
주의사항
-
변형 함수 안에서 검증을 한 번 더 하고 싶다면 두 번째 인자
ctx를 활용한다.z.string().transform((v, ctx) => { const n = Number(v) if (Number.isNaN(n)) { ctx.issues.push({ code: "custom", message: "숫자 변환 실패", input: v }) return z.NEVER } return n })
-
-
특징
-
.transform()뒤에는 일반 조건자(.min등)를 못 붙인다.z.string().transform((v) => Number(v)).min(0) // ❌ 컴파일 에러 -
변형 후 다시 검증을 걸려면
.pipe()로 다음 스키마에 넘긴다.z.string() .transform((v) => Number(v)) .pipe(z.number().int()) // ✅ 변형 결과를 다시 검증
-
.pipe(schema) : 검증/변형 결과를 다음 스키마로 넘긴다
-
무엇인가
-
한 스키마의 출력을 다음 스키마의 입력으로 연결하는 메서드이다.
const schema = z.string() .transform((v) => Number(v)) .pipe(z.number().int().positive()) // 입력 타입: string // 출력 타입: number (정수, 양수) schema.parse("42") // → 42 schema.parse("100") // → 100 schema.parse("3.14") // ❌ ZodError (3.14는 정수 아님) schema.parse("0") // ❌ ZodError (Number("0")=0, positive 위반) schema.parse("-5") // ❌ ZodError (Number("-5")=-5, positive 위반) schema.parse("") // ❌ ZodError (Number("")=0, positive 위반) schema.parse("abc") // ❌ ZodError (Number("abc")=NaN, int 위반) schema.parse(42) // ❌ ZodError (string 아님, transform 도달 전 실패) schema.parse(null) // ❌ ZodError
-
-
보통 어디에 쓰나
-
변형 후에 다시 검증을 걸어야 할 때, 즉
.transform()뒤에 조건자를 이어 붙이려 할 때 쓴다.// "10" → 10 으로 변환 후 양수 검증 const positiveFromString = z.string() .transform((v) => Number(v)) .pipe(z.number().positive()) positiveFromString.parse("10") // 10 ✅ positiveFromString.parse("-5") // ❌ (양수 아님) positiveFromString.parse("abc") // ❌ (NaN, 숫자 아님)
-
.preprocess(fn, schema) : 검사 전에 데이터를 미리 변형
-
무엇인가
-
검증 직전에 데이터를 한 번 가공한 뒤, 그 가공된 값을 스키마로 검사한다.
const schema = z.preprocess( (v) => (typeof v === "string" ? v.trim() : v), z.string().min(1) ) // 입력 타입: unknown (preprocess는 무엇이든 받음) // 출력 타입: string (1글자 이상) schema.parse(" hello ") // → "hello" (trim 후 검증) schema.parse("abc") // → "abc" schema.parse(" a ") // → "a" schema.parse(" ") // ❌ ZodError (trim 후 "", min(1) 위반) schema.parse("") // ❌ ZodError (trim 후 "", min(1) 위반) schema.parse(123) // ❌ ZodError (string 아님, 그대로 검증 단계로 → 실패) schema.parse(null) // ❌ ZodError (string 아님)
-
-
.transform()과의 차이-
.transform()은 검증 통과 후 동작하지만,.preprocess()는 검증 전에 들어오는 값을 손본다.// preprocess: 검증 전 변형 z.preprocess((v) => String(v).trim(), z.string().min(1)) .parse(" hello ") // "hello" 만들고 → min(1) 검증 통과 // transform: 검증 후 변형 z.string().min(1).transform((v) => v.trim()) .parse(" hello ") // " hello " 검증 후 → trim 적용
-
-
보통 어디에 쓰나
-
들어오는 원시 데이터를 정리한 다음에 표준 스키마로 검증하고 싶을 때 쓴다.
// 어떤 타입으로 들어오든 일단 문자열로 만들고 검증 const idSchema = z.preprocess( (v) => String(v), z.string().regex(/^\d+$/) ) idSchema.parse(123) // "123" → ✅ idSchema.parse("123") // "123" → ✅
-
5. 유틸리티
z.infer<typeof schema> : 스키마에서 타입 뽑기
-
무엇인가
-
스키마 정의로부터 정적 타입을 자동으로 추론해 주는 타입 유틸리티이다.
const userSchema = z.object({ id: z.number(), name: z.string() }) type User = z.infer<typeof userSchema> // { id: number; name: string } // parse 결과 타입과 일치 — 즉 "스키마가 만들어낼 값의 타입" const u: User = userSchema.parse({ id: 1, name: "a" }) // ✅ const x: User = { id: "1", name: "a" } // ❌ TS 에러 (id가 string) // 어떤 스키마에든 사용 가능 type S = z.infer<typeof z.string()> // string type Arr = z.infer<typeof z.array(z.number())> // number[] type Optional = z.infer<typeof z.string().optional()> // string | undefined // transform이 있으면 "변환 후" 타입이 추론됨 const lengthSchema = z.string().transform(v => v.length) type L = z.infer<typeof lengthSchema> // number (output 타입) // 변환 전/후를 명시적으로 구분하려면: type In = z.input<typeof lengthSchema> // string type Out = z.output<typeof lengthSchema> // number (z.infer와 동일)
-
-
특징
-
스키마와 타입을 따로 정의할 필요가 없어 둘이 어긋날 가능성을 원천 차단한다.
// 스키마 한 번만 정의 const userSchema = z.object({ id: z.number(), name: z.string() }) // 런타임 검증 const user = userSchema.parse(data) // 같은 스키마에서 타입 추출 → 절대 어긋날 수 없음 type User = z.infer<typeof userSchema> -
입력과 출력 타입이 다른 경우(
.transform,.default)에는z.input<>과z.output<>을 구분해 쓸 수 있다.const schema = z.string().default("guest") type In = z.input<typeof schema> // string | undefined type Out = z.output<typeof schema> // string
-
.parse / .safeParse / .parseAsync : 검증 실행
-
무엇인가
-
Zod스키마로 실제 데이터를 검증하는 실행 메서드이다. -
스키마를 정의하는 것만으로는 아무 일도 일어나지 않고,
.parse(데이터)를 호출해야 실제로 검증이 돌아간다. -
통과하면 검증된 데이터를 반환하고, 실패하면
ZodError예외를 던진다. -
반환값은 단순히 입력을 그대로 돌려주는 게 아니라 스키마 타입에 맞게 추론된 값이라, 그 뒤 코드에서 타입 안전하게 쓸 수 있다.
const user = userSchema.parse(raw) // user의 타입은 { name: string; age: number } 으로 좀혀짐 user.name.toUpperCase() // ✅ 타입 안전 -
예외를 던지는 게 부담스러우면
.safeParse()를 쓰는데,{ success: true, data }또는{ success: false, error }형태의 객체를 반환해 try/catch 없이 분기할 수 있게 해준다.const userSchema = z.object({ name: z.string(), age: z.number() }) // safeParse는 throw 안 함. 결과 객체를 돌려줌: // 성공 시 → { success: true, data: <검증된 값> } // 실패 시 → { success: false, error: ZodError } const result = userSchema.safeParse(raw) if (!result.success) { console.error(result.error.issues) // 이 분기에선 result.error만 존재 return } // 이 아래에선 result.data가 보장된 타입으로 좁혀짐 (TS narrowing) result.data.name.toUpperCase() // ✅ result.data.age + 1 // ✅ // 실제 결과 형태 userSchema.safeParse({ name: "Alice", age: 30 }) // → { success: true, data: { name: "Alice", age: 30 } } userSchema.safeParse({ name: "Alice", age: "30" }) // → { success: false, error: ZodError } // issues: [{ path: ["age"], code: "invalid_type", message: "..." }] userSchema.safeParse({ name: 123, age: "x" }) // → { success: false, error: ZodError } // issues: 2개 (name, age 둘 다 실패) — 한 번에 모두 모아서 반환
-
-
보통 어디에 쓰나
-
.parse는 실패 시 예외를 던지므로 try/catch와 함께 쓰거나, 신뢰 가능한 환경(서버 내부 등)에 쓴다.try { const user = userSchema.parse(rawData) } catch (e) { if (e instanceof z.ZodError) console.error(e.issues) } -
.safeParse는 폼 검증처럼 실패를 정상 흐름으로 다뤄야 하는 곳에 적합하다.const result = userSchema.safeParse(formData) if (!result.success) { // result.error.issues 로 폼 에러 표시 return } // result.data 사용
-
-
특징
-
비동기 검사가 포함된 스키마(
.refine의 async 콜백 등)는 반드시.parseAsync/.safeParseAsync로 호출해야 한다.const usernameSchema = z.string().refine( async (v) => await checkAvailable(v), { message: "이미 사용 중인 이름" } ) // usernameSchema.parse("alice") ❌ 동기 호출은 동작 안 함 await usernameSchema.parseAsync("alice") // ✅
-
.partial / .required : 키 단위 옵셔널 토글
-
무엇인가
-
객체 스키마의 모든 키를 한꺼번에
.optional()처리하거나(.partial()), 그 반대로 모두 필수로 바꾸는(.required()) 메서드이다.const userSchema = z.object({ id: z.number(), name: z.string(), }) // .partial() — 모든 필드를 optional로 const partialUser = userSchema.partial() type P = z.infer<typeof partialUser> // { id?: number; name?: string } partialUser.parse({ id: 1, name: "a" }) // → { id: 1, name: "a" } partialUser.parse({ id: 1 }) // → { id: 1 } partialUser.parse({ name: "a" }) // → { name: "a" } partialUser.parse({}) // → {} partialUser.parse({ id: "1" }) // ❌ ZodError (있다면 타입은 맞아야 함) // .required() — optional 필드를 다시 필수로 const baseSchema = z.object({ id: z.number(), name: z.string().optional(), }) const requiredSchema = baseSchema.required() type R = z.infer<typeof requiredSchema> // { id: number; name: string } requiredSchema.parse({ id: 1, name: "a" }) // → { id: 1, name: "a" } requiredSchema.parse({ id: 1 }) // ❌ ZodError (name 누락) // 일부 필드만 선택적으로 적용 가능 userSchema.partial({ name: true }) // { id: number; name?: string } baseSchema.required({ name: true }) // { id: number; name: string }
-
-
보통 어디에 쓰나
-
PATCH요청 바디 스키마처럼 전체 키 중 일부만 보내는 입력에.partial()을 쓴다.const updateUserSchema = userSchema.partial() updateUserSchema.parse({ name: "new name" }) // ✅ id 생략 가능 updateUserSchema.parse({ id: 1 }) // ✅ name 생략 가능 updateUserSchema.parse({}) // ✅ 둘 다 생략 가능 updateUserSchema.parse({ name: 123 }) // ❌ 있다면 타입은 맞아야 함
-
-
특징
-
.partial()은 1차 깊이만 옵셔널로 바꾼다. 중첩까지 옵셔널이 필요하면 각 중첩 객체에 직접.partial()을 적용한다.const nested = z.object({ user: z.object({ id: z.number(), name: z.string() }), }) nested.partial() // { user?: { id: number; name: string } } (1차만 옵셔널) // 중첩까지 모두 옵셔널이 필요하면 직접 풀어서 z.object({ user: z.object({ id: z.number(), name: z.string() }).partial().optional(), }) // { user?: { id?: number; name?: string } }
-
.pick / .omit : 키 선택/제외
-
무엇인가
-
객체 스키마에서 특정 키들만 골라 새 스키마를 만들거나(
.pick), 특정 키들을 빼고 만든다(.omit).const userSchema = z.object({ id: z.number(), name: z.string(), pw: z.string(), }) // .pick({ ... }) — 지정한 키만 남김 const publicUser = userSchema.pick({ id: true, name: true }) type Pub = z.infer<typeof publicUser> // { id: number; name: string } publicUser.parse({ id: 1, name: "a" }) // → { id: 1, name: "a" } publicUser.parse({ id: 1, name: "a", pw: "x" })// → { id: 1, name: "a" } (pw는 strip) publicUser.parse({ id: 1 }) // ❌ ZodError (name 누락) // .omit({ ... }) — 지정한 키만 제거 (나머지는 그대로) const safeUser = userSchema.omit({ pw: true }) type Safe = z.infer<typeof safeUser> // { id: number; name: string } safeUser.parse({ id: 1, name: "a" }) // → { id: 1, name: "a" } safeUser.parse({ id: 1, name: "a", pw: "x" }) // → { id: 1, name: "a" } (pw는 strip) safeUser.parse({ id: 1, pw: "x" }) // ❌ ZodError (name 누락) // pick / omit 결과는 새 ZodObject이므로 그대로 체이닝 가능 userSchema.omit({ pw: true }).partial() // { id?: number; name?: string }
-
-
보통 어디에 쓰나
-
생성용 입력(
pw포함)과 응답용 출력(pw제외)처럼 같은 자원에서 일부만 노출해야 할 때 쓴다.const createInput = userSchema.omit({ id: true }) // 생성 시 id 제외 const publicUser = userSchema.omit({ pw: true }) // 응답 시 pw 제외 createInput.parse({ name: "a", pw: "x" }) // ✅ id 없이 통과 createInput.parse({ id: 1, name: "a", pw: "x" }) // ✅ id는 strip createInput.parse({ name: "a" }) // ❌ pw 누락 publicUser.parse({ id: 1, name: "a" }) // ✅ pw 없이 통과 publicUser.parse({ id: 1, name: "a", pw: "x" }) // ✅ pw는 strip
-
.extend : 객체 스키마 합치기
-
무엇인가
-
기존 객체 스키마에 새 키를 추가하거나 다른 객체 스키마와 합칠 때
.extend를 쓴다.const base = z.object({ id: z.number() }) // .extend() — 권장 방식 const extended = base.extend({ name: z.string() }) type E = z.infer<typeof extended> // { id: number; name: string } extended.parse({ id: 1, name: "a" }) // → { id: 1, name: "a" } extended.parse({ id: 1 }) // ❌ ZodError (name 누락) extended.parse({ name: "a" }) // ❌ ZodError (id 누락) // spread + .shape — tsc 성능이 가장 좋음 (대형 스키마에서 유리) const spread = z.object({ ...base.shape, // 객체 꺼내기 name: z.string(), }) type S = z.infer<typeof spread> // { id: number; name: string } spread.parse({ id: 1, name: "a" }) // → { id: 1, name: "a" } // 같은 키가 겹치면 뒤쪽이 덮어씀 const overridden = base.extend({ id: z.string() }) type O = z.infer<typeof overridden> // { id: string } ← number에서 덮어쓰여짐 overridden.parse({ id: "abc" }) // → { id: "abc" } overridden.parse({ id: 1 }) // ❌ ZodError (이젠 string이어야 함)
-
-
보통 어디에 쓰나
-
공통 필드를 베이스 스키마로 두고, 도메인별로 추가 필드를 얹어 확장할 때 쓴다.
const timestamps = z.object({ createdAt: z.date(), updatedAt: z.date() }) const post = z.object({ title: z.string() }).extend(timestamps.shape) const comment = z.object({ text: z.string() }).extend(timestamps.shape) // 모든 도메인이 timestamps 필드를 공유
-
-
특징
-
같은 이름의 키가 있으면 나중에 합치는 쪽이 덮어쓴다.
const a = z.object({ name: z.string() }) const b = z.object({ name: z.number() }) a.extend(b.shape) // { name: number } → b가 덮어씀
-
.shape / .keyof : 스키마 내부 정보 접근
-
무엇인가
-
.shape로 객체 스키마의 각 필드 스키마에 접근할 수 있고,.keyof()로 키 이름의 enum 스키마를 만들 수 있다.const userSchema = z.object({ id: z.number(), name: z.string(), }) // .shape — 키-스키마 매핑 객체에 접근 userSchema.shape // → { id: z.number(), name: z.string() } userSchema.shape.id // → z.number() (개별 필드 스키마) userSchema.shape.name // → z.string() // 꺼낸 스키마는 독립적으로 사용 가능 userSchema.shape.id.parse(1) // → 1 userSchema.shape.id.parse("1") // ❌ ZodError (number 아님) // spread로 새 스키마 조립 z.object({ ...userSchema.shape, email: z.string() }) // → { id: number, name: string, email: string } // .keyof() — 키 이름들로 만든 z.enum 스키마 const keys = userSchema.keyof() // → z.enum(["id", "name"]) type K = z.infer<typeof keys> // "id" | "name" keys.parse("id") // → "id" keys.parse("name") // → "name" keys.parse("age") // ❌ ZodError (정의된 키 아님)
-
-
보통 어디에 쓰나
-
다른 스키마를 만들 때 필드 스키마를 재활용하거나, 정렬 키처럼 키 이름 자체가 값이 되는 곳에 쓴다.
// userSchema의 id 필드 스키마 재활용 const idSchema = userSchema.shape.id // 정렬 키로 키 이름만 허용 const sortKey = userSchema.keyof() // "id" | "name" 만 통과 const querySchema = z.object({ sortBy: sortKey, order: z.enum(["asc", "desc"]), })
-