프로필 로고
2026-05-21

Zod

TypeScript의 런타임 검증 한계를 해결하는 Zod의 사용 파이프라인(스키마·조건자·변경자·변형자)과 z.object·z.array·z.discriminatedUnion·.refine·.transform·.pipe 등 주요 API를 예시 중심으로 정리한다.

  • Zod

타입스크립트의 한계와 데이터 불일치 문제

  1. 타입스크립트는 코드를 작성하고 컴파일하는 시점에만 타입의 일치 여부를 검사한다.

  2. 실제 애플리케이션이 동작하는 런타임 환경에서는 타입스크립트의 검사 기능과 타입 정보가 모두 제거된다.

  3. 외부 API에서 받아오는 응답 데이터는 서버의 로직 변경이나 통신 문제로 인해 형태가 언제든 달라질 수 있다.

  4. 만약 서버가 name이라는 키 대신 firstName을 보내더라도 타입스크립트는 이를 런타임에 인지하지 못한다.

  5. 결국 데이터가 타입과 같다고 착각한 상태로 내부 로직을 실행하다가 없는 속성에 접근하여 프로그램이 비정상 종료된다.

방어적 프로그래밍의 한계와 Zod의 도입

  1. 런타임 오류를 막기 위해 과거에는 데이터가 쓰이는 모든 내부 함수에서 값이 존재하는지 일일이 확인해야 했다.

  2. 이렇게 내부 로직마다 데이터의 유효성을 계속 검사하는 방어적 프로그래밍은 코드를 길고 복잡하게 만든다.

  3. 이 문제를 해결하기 위해 애플리케이션과 외부 API가 만나는 최초의 경계에서 데이터를 한 번만 검사하는 구조가 필요해졌다.

  4. Zod는 이 외부 경계에서 parse 함수나 safeParse 함수를 이용해 데이터가 사전에 정의된 스키마와 정확히 일치하는지 실행 시점에 확인한다.

  5. 검증을 통과하지 못한 오염된 데이터는 애플리케이션 내부로 진입하지 못하고 경계에서 즉시 차단된다.

🔗 사용 파이프라인

1. 유틸리티

이미 정의된 스키마를 조각내거나 합쳐 파생 스키마를 만들어두는 준비 단계이다.

  1. 스키마를 매번 처음부터 정의하면 중복되고, 베이스가 바뀔 때 파생이 어긋난다.

  2. 만약 베이스 하나에서 파생한 뒤 한 곳만 고친다면 나머지를 재사용할 수 있다.

  3. 예를 들어, 같은 도메인(예: User)이라도 생성용은 id를 빼고, 수정용은 전부 옵셔널로, 응답용은 비밀번호를 빼는 식으로 모양을 조금씩 변형해서 사용할 수 있다.

2. 입력

검증되지 않은 원시 데이터가 파이프라인에 들어오는 진입점이다.

  1. TypeScript의 타입은 컴파일 시점에만 존재해서 외부 데이터에는 아무 보장도 주지 못한다.

  2. API 응답·폼·환경 변수처럼 외부에서 들어오는 값은 모두 이 자리를 통과한다.

  3. 이 자리를 "외부 경계"로 인식해야 어디서부터 검증이 시작되어야 하는지 명확해진다.

3. 변형자(검사 전)

원시 데이터를 그대로 검증하면 실패하는 경우, 검증 직전에 표준 형태로 정리하는 단계이다.

  1. 외부에서 들어온 값의 타입·형태는 우리가 검증 규칙으로 기대하는 것과 어긋나는 경우가 많다.

  2. 환경 변수와 URL 쿼리스트링 등은 대부분 문자열로 들어온다.

  3. 그리고, 폼 입력에는 공백이 섞이고, 외부 API가 숫자를 문자열로 보내기도 한다.

  4. 형 변환과 정리를 검사 전에 한 번 거쳐야 뒤따르는 모양·범위 검증이 의미를 가진다.

4. 스키마

데이터가 어떤 모양(타입과 구조)이어야 하는지 선언하고, 들어온 값이 그 모양을 따르는지 검사하는 골격 단계이다.

  1. "이 데이터의 구조는 무엇인가"를 한 곳에서 선언하는 것이 Zod의 핵심이다.

  2. 같은 정의가 런타임 검증과 정적 타입 추론을 동시에 만들어낸다.

  3. 모양이 어긋난 데이터는 여기서 걸러져, 이후 단계는 안전한 모양 위에서 동작한다.

5. 조건자

모양 검사를 통과한 값에 대해 값 자체의 유효 규칙을 추가로 검사하는 단계이다.

  1. 타입이 string이라고 곧 이메일은 아니고, number라고 곧 양수는 아니다.

  2. "1~100 사이", "이메일 형식", "비밀번호에 대문자 포함" 같은 도메인 규칙이 여기에 들어간다.

  3. 모양 검사로 잡지 못하는 값의 유효성을 표현하는 자리이다.

6. 변경자

스키마 자체의 메타데이터나 검증 실패 시 동작 방식을 조정하는 단계이다.

  1. 스키마는 단순 검증을 넘어 OpenAPI 변환, 폼 빌더, 문서 생성 같은 외부 도구에서도 활용된다.

  2. 그래서 설명·예시 같은 메타데이터를 스키마에 함께 붙여둘 자리가 필요하다.

  3. 또한, 검증 실패를 예외로 던지지 않고 기본값으로 복구해야 할 때도 있다.

  4. 검증 로직 자체는 그대로 두고 스키마의 부가 동작만 조정하는 자리이다.

6. 변형자 (검사 후)

검증을 통과한 값을 후속 코드가 쓰기 편한 최종 형태로 가공하는 단계이다.

  1. 외부 데이터 모양과 내부에서 쓰기 좋은 모양이 같지 않을 때가 많다.

  2. snake_casecamelCase 매핑이나 콤마 구분 문자열 → 배열 같은 변환이 여기에 들어간다.

  3. 검증 직후에 변환을 모아두면 이후 코드는 항상 내부 표준 모양만 다루면 된다.

7. 출력

전체 파이프라인을 실제로 실행해 타입이 보장된 결과 데이터를 꺼내는 단계이다.

  1. 앞 단계들은 모두 "스키마 정의"일 뿐, 호출 전에는 아무 일도 일어나지 않는다.

  2. .parse().safeParse()를 부르는 시점에서야 실제로 검증이 돌아간다.

  3. 실패를 예외로 받을지(.parse()), 객체로 받을지(.safeParse()) 이 자리에서 결정한다.

1. 스키마

z 객체는 무엇인가

  1. 무엇인가
    • zZod가 제공하는 모든 스키마 생성 함수가 모여 있는 네임스페이스이다.

      import { z } from "zod"
       
      z.string()         // 문자열 스키마
      z.number()         // 숫자 스키마
      z.object({ ... })  // 객체 스키마

원시 타입 (z.string, z.number, z.boolean 등) : 가장 기본 단위 스키마

  1. 무엇인가

    • 단일 값 하나의 타입을 검사하는 가장 기본적인 스키마이다.

    • 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 (반환값 없음)
  2. 타입

    • 각각 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
  3. 특징

    • 원시 타입은 인자 없이 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 : 객체 모양 검사

  1. 무엇인가

    • 객체의 키와 각 값의 타입을 정의하는 스키마이다.

    • 가장 자주 쓰이는 컨테이너 스키마이며, 폼·API 응답의 골격을 표현하는 데 사용된다.

      const userSchema = z.object({
        id: z.number(),
        name: z.string(),
        email: z.string().email(),
      })
  2. 타입

    • 각 키의 스키마 타입을 합친 객체 타입으로 추론된다.

      type User = z.infer<typeof userSchema>
      // { id: number; name: string; email: string }
  3. 보통 어디에 쓰나

    • 폼 데이터, API 요청/응답 바디, 설정 객체 등 키-값 구조를 검사할 때 사용한다.

      // API 응답 바디 검증
      const apiResponse = z.object({
        status: z.number(),
        data: z.object({
          id: z.number(),
          name: z.string(),
        }),
      })
       
      apiResponse.parse(await res.json())
  4. 특징

    • 기본 동작은 정의되지 않은 추가 키를 조용히 제거하는 것이다.

      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 : 배열 검사

  1. 무엇인가

    • 배열의 각 원소 타입이 동일한 스키마를 따르는지 검사한다.

      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 배열에 여러 개가 들어올 수 있다.
  2. 타입

    • T[]로 추론된다.

      const nums = z.array(z.number())
      type Nums = z.infer<typeof nums>  // number[]
  3. 보통 어디에 쓰나

    • 동일한 모양의 항목들이 반복되는 리스트형 데이터(상품 목록, 댓글 목록 등)에 쓴다.

      // 상품 목록 스키마
      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 반환 아님)
  4. 특징

    • 원소 개수 제약은 .min(), .max(), .length(), .nonempty() 같은 조건자로 추가한다.

      z.array(z.string()).min(1).max(10)     // 원소 1~10개
      z.array(z.string()).nonempty()         // 최소 1개 (빈 배열 거부)

z.record : 키가 가변인 객체

  1. 무엇인가

    • 키 이름이 미리 정해져 있지 않고, 모든 값이 같은 타입을 가지는 객체 스키마이다.

    • z.object가 "어떤 키들이 있는지" 정의한다면, z.record는 "어떤 키가 와도 값은 이 타입"이라는 식으로 정의한다.

    • 키 스키마와 값 스키마를 둘 다 명시해야 한다.

      z.record(z.string(), z.number())
      // { [key: string]: number }
      // { a: 1, b: 2, anything: 3 } ✅
  2. 타입

    • Record<string, T>로 추론된다.

      const scores = z.record(z.string(), z.number())
      type Scores = z.infer<typeof scores>  // Record<string, number>
  3. 보통 어디에 쓰나

    • 사전(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 아님)
  4. 특징

    • 첫 번째 인자로 키 스키마, 두 번째 인자로 값 스키마를 받는다.

    • 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 : 길이와 위치별 타입이 고정된 배열

  1. 무엇인가

    • 배열이긴 한데 위치마다 타입이 다른 경우에 사용한다.

      z.tuple([z.string(), z.number(), z.boolean()])
      // [string, number, boolean]
  2. 타입

    • 위치별 타입을 그대로 보존한 튜플 타입으로 추론된다.

      const t = z.tuple([z.string(), z.number()])
      type T = z.infer<typeof t>  // [string, number]
  3. 보통 어디에 쓰나

    • [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 : 정해진 값 중 하나

  1. 무엇인가

    • 미리 정의한 문자열(또는 TypeScript enum) 목록 중 하나의 값만 허용한다.

      const Role = z.enum(["admin", "user", "guest"])
      Role.parse("admin")  // ✅
      Role.parse("other")  // ❌
  2. 타입

    • 리터럴 유니온 타입으로 추론된다.

      type Role = z.infer<typeof Role>  // "admin" | "user" | "guest"
  3. 보통 어디에 쓰나

    • 권한, 상태값, 카테고리처럼 후보가 정해진 필드에 쓴다.

      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 누락)
    1. 특징
    • z.enum()은 문자열 리터럴 배열뿐 아니라 TypeScript의 enum 키워드로 만든 enum도 직접 받는다.

      enum Color { Red = "RED", Blue = "BLUE" }
       
      const ColorSchema = z.enum(Color)
      ColorSchema.parse("RED")   // ✅
      ColorSchema.parse("GREEN") // ❌

z.literal : 정확히 그 값 하나

  1. 무엇인가

    • 특정 리터럴 값 하나만 통과시키는 스키마이다.

      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"'
      // }]
  2. 타입

    • 해당 리터럴 타입 하나로 추론된다.

      const tag = z.literal("text")
      type Tag = z.infer<typeof tag>  // "text" (리터럴)
  3. 보통 어디에 쓰나

    • 판별자(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 : 여러 스키마 중 하나

  1. 무엇인가

    • 여러 후보 스키마 중 하나에라도 통과하면 성공인 합집합 스키마이다.

      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() 시도 실패 사유들
      //   ]
      // }]
  2. 타입

    • 각 후보 스키마의 유니온으로 추론된다.

      type U = z.infer<typeof u>  // string | number
  3. 특징

    • 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 : 재귀 스키마 (스키마 안에서 자기 자신을 다시 써야 할 때)

  1. 무엇인가

    1. 예를 들어, 댓글의 경우 답글이 연속적으로 달릴 수 있다.

      type Comment = {
        text: string
        replies: Comment[]   // ← 자기 자신 타입의 배열
      }
    2. 이 구조를 zod로 옮기다보면 문제가 생긴다.

      const Comment =           // ← 이 줄이 끝나야 Comment에 값이 들어감
        z.object({              // ← 근데 이걸 평가해야 그 값이 만들어짐
          text: z.string(),
          replies: z.array(Comment)   // ← 평가 중에 Comment를 읽으려고 함
                                      //   근데 Comment는 아직 값이 없음!
        })
    3. 자바스크립트 실행 순서를 따라가보면, 변수에 대입하기 전에 읽으려 하기 때문이다.

    4. 자바스크립트에서 “나중에 실행”하는 방법은 함수로 감싸면 된다.

      // 함수 정의: 본문은 아직 실행 안 됨
      const later = () => Comment
       
      // 이 줄 시점엔 Comment가 아직 없어도 OK
      // 누군가 later()를 호출하는 그 시점에야 Comment를 읽음
    5. 이렇게 하면 함수 본문은 정의될 때가 아니라, 호출될 때 실행된다.

    6. z.lazy(fn)이 하는 일은 스키마 정의를 함수로 감싸서, 실제 파싱이 일어날 때까지 평가를 미룬다.

      const Comment: z.ZodType<CommentType> = z.lazy(() =>
        z.object({
          text: z.string(),
          replies: z.array(Comment)   // ✅ 함수 안이라 지금 실행 안 됨
                                      //    parse() 호출될 때 그제서야 평가
                                      //    그때는 Comment가 이미 정의 완료
        })
      )
  2. 주의사항

    • 재귀 스키마는 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.* : 검사 전에 강제 형변환

  1. 무엇인가

    • 검증에 들어가기 전에 값을 원하는 타입으로 강제로 변환하는 스키마군이다.

      // 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 (소수점 불가)
  2. 보통 어디에 쓰나

    • 폼 입력값(항상 문자열), URL 쿼리스트링, 환경 변수처럼 들어올 때 무조건 문자열인 값을 숫자나 날짜 등으로 바꿔야 할 때 쓴다.

      // 환경 변수는 process.env에서 항상 string
      const envSchema = z.object({
        PORT: z.coerce.number(),    // "3000" → 3000
        DEBUG: z.coerce.boolean(),  // "true" → true (주의)
      })
       
      envSchema.parse(process.env)
  3. 주의사항

    • 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 : 특수 스키마

  1. 무엇인가

    • 검증을 거치지 않거나(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
  2. 특징

    • 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) : 길이·크기 제약

  1. 무엇인가

    • 문자열의 글자 수, 배열의 원소 개수, 숫자의 값 범위를 제한한다.

      // 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은 숫자 아님)
  2. 특징

    • .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 : 문자열 형식 검사

  1. 무엇인가

    • 문자열이 특정 형식(이메일, 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() 사용 권장
  2. 보통 어디에 쓰나

    • 폼 입력값을 정규식으로 직접 짜기 전에 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 : 부분 문자열 검사

  1. 무엇인가

    • 문자열이 특정 부분 문자열로 시작하거나 끝나거나 포함하는지 검사한다.

      // 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 (@ 없음)
  2. 보통 어디에 쓰나

    • 프로토콜 강제, 도메인 검증, 키워드 포함 검사 등에 쓴다.

      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 : 숫자 제약

  1. 무엇인가
    • 숫자의 범위와 형태를 세밀하게 제약하는 조건자들이다.

      // 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보다 큰 수만 통과시킨다.
  2. 특징
    • .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. 무엇인가

    • 내장 조건자로 표현 불가한 규칙을 함수로 직접 작성하는 메서드이다.

      // 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 도달 전 실패)
  2. 타입

    • 입력 타입은 바꾸지 않고, 통과 여부만 결정한다.

      const trimmed = z.string().refine((v) => v === v.trim())
      type T = z.infer<typeof trimmed>  // string (변화 없음)
  3. 보통 어디에 쓰나

    • 비밀번호 강도, 두 필드 값 일치 여부 등 표준 조건자로 못 표현하는 비즈니스 규칙에 쓴다.

      const strongPassword = z.string().refine(
        (v) => /[A-Z]/.test(v) && /[0-9]/.test(v),
        { error: "대문자와 숫자를 포함해야 합니다" }
      )
  4. 특징

    • 객체에 붙이면 두 필드를 비교하는 검사도 가능하다.

      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) : 여러 이슈를 한 번에 추가

  1. 무엇인가

    • .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 도달 전 실패)
  2. 보통 어디에 쓰나

    • 한 필드에서 여러 검사를 동시에 돌려 모든 위반을 한꺼번에 보여주고 싶을 때 사용한다.

      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) : 설명 메타 부여

  1. 무엇인가

    • 스키마에 사람이 읽을 설명을 붙이는 메서드이다.

      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: "사용자의 이메일" }
  2. 보통 어디에 쓰나

    • 폼 자동 생성, OpenAPI 스키마 변환, 에러 메시지 가공 등 외부 도구에서 스키마 메타를 읽을 때 활용된다.

      const userSchema = z.object({
        email: z.string().email().describe("사용자 이메일"),
        age: z.number().int().describe("나이 (만)"),
      })
       
      // OpenAPI 변환 라이브러리가 description 필드로 활용
      // 폼 빌더가 라벨로 활용

.brand<T>() : 명목적 타입 부여

  1. 무엇인가

    • 구조가 같아도 의미가 다른 타입을 구분하기 위해 타입에 "브랜드"라는 표시를 붙이는 메서드이다.

      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 아님)
  2. 보통 어디에 쓰나

    • 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() : 읽기 전용 추론

  1. 무엇인가

    • 추론되는 타입을 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[]
  2. 보통 어디에 쓰나

    • 검증 결과를 의도치 않게 수정하지 못하도록 막을 때 쓴다.

      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) : 실패 시 기본값으로 대체 (에러를 던지지 않는다)

  1. 무엇인가

    • 검증이 실패해도 에러를 던지지 않고, 지정한 기본값으로 대체하도록 만든다.

      // 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)
  2. .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
  3. 주의사항

    • 모든 에러를 삼키므로 사용자 입력 검증에는 잘 어울리지 않는다.

      // ❌ 폼 검증에 쓰면 잘못된 입력도 그냥 통과
      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() : 있어도 되고 없어도 되고

  1. 무엇인가

    • 해당 필드가 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 누락)
  2. 타입

    • T | undefined

      const 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 가 합쳐짐
      // }
  3. 보통 어디에 붙이나

    • 사용자가 비워둘 수 있는 선택 입력 폼 필드 (메모, 부가 옵션 등)

      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(), // 삭제된 항목에만 존재
      })
  4. 주의사항

    • 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)  // ❌ 컴파일 에러
  5. 특징

    • .optional() 위치는 체이닝 맨 뒤에 두는 게 일반적이다. 앞쪽 검증을 다 거친 뒤 "이게 undefined 면 통과" 로 읽혀서 의도가 명확해진다.

      z.string().min(1).max(100).optional()  // 권장

.nullable() : null 도 통과시킨다

  1. 무엇인가

    • 해당 필드가 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 아님)
  2. 타입

    • T | null

      const schema = z.string().nullable()
      type V = z.infer<typeof schema>  // string | null
  3. 보통 어디에 붙이나

    • 백엔드 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 (필수 키 누락)
  4. 주의사항

    • undefined는 통과시키지 않는다.

      z.string().nullable().parse(null)       // ✅
      z.string().nullable().parse(undefined)  // ❌
    • nullundefined를 모두 허용해야 한다면 .nullish()를 쓴다.

      z.string().nullish().parse(null)        // ✅
      z.string().nullish().parse(undefined)   // ✅

.nullish() : null 과 undefined 모두 통과

  1. 무엇인가

    • .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 아님)
  2. 타입

    • T | null | undefined

      const schema = z.string().nullish()
      type V = z.infer<typeof schema>  // string | null | undefined
  3. 보통 어디에 붙이나

    • 백엔드가 동일한 필드를 어떨 땐 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) : 비어 있으면 기본값을 채워준다

  1. 무엇인가

    • 입력이 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 트리거 안 함)
  2. 타입

    • 입력 타입은 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 }
  3. 보통 어디에 붙이나

    • 사용자가 안 채워도 합리적인 기본값이 정해진 필드(권한 기본값, 페이지 사이즈 기본값 등)에 붙인다.

      z.object({
        pageSize: z.number().default(20),
        sort: z.enum(["asc", "desc"]).default("asc"),
      })
    • 기본값이 동적이어야 한다면 함수 형태로도 넘길 수 있다.

      z.object({
        createdAt: z.date().default(() => new Date()),
      })
  4. 주의사항

    • 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" ✅
  5. 특징

    • 출력 타입이 T(non-optional)이 되므로 후속 로직에서 undefined 처리 코드를 안 써도 된다.

      const schema = z.object({ role: z.string().default("user") })
      const { role } = schema.parse({})
      role.toUpperCase()  // ✅ role은 string으로 확정됨

.transform(fn) : 검증 후에 값을 변형

  1. 무엇인가

    • 스키마 검증을 통과한 값을 함수로 한 번 더 가공해 새 값으로 만든다.

      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 아님)
  2. 타입

    • 입력 타입과 출력 타입이 달라질 수 있다.

      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
  3. 보통 어디에 붙이나

    • 입력 정규화(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 }))
  4. 주의사항

    • 변형 함수 안에서 검증을 한 번 더 하고 싶다면 두 번째 인자 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
      })
  5. 특징

    • .transform() 뒤에는 일반 조건자(.min 등)를 못 붙인다.

      z.string().transform((v) => Number(v)).min(0)  // ❌ 컴파일 에러
    • 변형 후 다시 검증을 걸려면 .pipe()로 다음 스키마에 넘긴다.

      z.string()
        .transform((v) => Number(v))
        .pipe(z.number().int())  // ✅ 변형 결과를 다시 검증

.pipe(schema) : 검증/변형 결과를 다음 스키마로 넘긴다

  1. 무엇인가

    • 한 스키마의 출력을 다음 스키마의 입력으로 연결하는 메서드이다.

      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
  2. 보통 어디에 쓰나

    • 변형 후에 다시 검증을 걸어야 할 때, 즉 .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) : 검사 전에 데이터를 미리 변형

  1. 무엇인가

    • 검증 직전에 데이터를 한 번 가공한 뒤, 그 가공된 값을 스키마로 검사한다.

      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 아님)
  2. .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 적용
  3. 보통 어디에 쓰나

    • 들어오는 원시 데이터를 정리한 다음에 표준 스키마로 검증하고 싶을 때 쓴다.

      // 어떤 타입으로 들어오든 일단 문자열로 만들고 검증
      const idSchema = z.preprocess(
        (v) => String(v),
        z.string().regex(/^\d+$/)
      )
       
      idSchema.parse(123)     // "123" → ✅
      idSchema.parse("123")   // "123" → ✅

5. 유틸리티

z.infer<typeof schema> : 스키마에서 타입 뽑기

  1. 무엇인가

    • 스키마 정의로부터 정적 타입을 자동으로 추론해 주는 타입 유틸리티이다.

      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와 동일)
  2. 특징

    • 스키마와 타입을 따로 정의할 필요가 없어 둘이 어긋날 가능성을 원천 차단한다.

      // 스키마 한 번만 정의
      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 : 검증 실행

  1. 무엇인가

    • 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 둘 다 실패) — 한 번에 모두 모아서 반환
  2. 보통 어디에 쓰나

    • .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 사용
  3. 특징

    • 비동기 검사가 포함된 스키마(.refine의 async 콜백 등)는 반드시 .parseAsync / .safeParseAsync로 호출해야 한다.

      const usernameSchema = z.string().refine(
        async (v) => await checkAvailable(v),
        { message: "이미 사용 중인 이름" }
      )
       
      // usernameSchema.parse("alice")      ❌ 동기 호출은 동작 안 함
      await usernameSchema.parseAsync("alice")  // ✅

.partial / .required : 키 단위 옵셔널 토글

  1. 무엇인가

    • 객체 스키마의 모든 키를 한꺼번에 .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 }
  2. 보통 어디에 쓰나

    • PATCH 요청 바디 스키마처럼 전체 키 중 일부만 보내는 입력.partial()을 쓴다.

      const updateUserSchema = userSchema.partial()
       
      updateUserSchema.parse({ name: "new name" })  // ✅ id 생략 가능
      updateUserSchema.parse({ id: 1 })             // ✅ name 생략 가능
      updateUserSchema.parse({})                    // ✅ 둘 다 생략 가능
      updateUserSchema.parse({ name: 123 })         // ❌ 있다면 타입은 맞아야 함
  3. 특징

    • .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 : 키 선택/제외

  1. 무엇인가

    • 객체 스키마에서 특정 키들만 골라 새 스키마를 만들거나(.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 }
  2. 보통 어디에 쓰나

    • 생성용 입력(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 : 객체 스키마 합치기

  1. 무엇인가

    • 기존 객체 스키마에 새 키를 추가하거나 다른 객체 스키마와 합칠 때 .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이어야 함)
  2. 보통 어디에 쓰나

    • 공통 필드를 베이스 스키마로 두고, 도메인별로 추가 필드를 얹어 확장할 때 쓴다.

      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 필드를 공유
  3. 특징

    • 같은 이름의 키가 있으면 나중에 합치는 쪽이 덮어쓴다.

      const a = z.object({ name: z.string() })
      const b = z.object({ name: z.number() })
       
      a.extend(b.shape)  // { name: number } → b가 덮어씀

.shape / .keyof : 스키마 내부 정보 접근

  1. 무엇인가

    • .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 (정의된 키 아님)
  2. 보통 어디에 쓰나

    • 다른 스키마를 만들 때 필드 스키마를 재활용하거나, 정렬 키처럼 키 이름 자체가 값이 되는 곳에 쓴다.

      // userSchema의 id 필드 스키마 재활용
      const idSchema = userSchema.shape.id
       
      // 정렬 키로 키 이름만 허용
      const sortKey = userSchema.keyof()  // "id" | "name" 만 통과
      const querySchema = z.object({
        sortBy: sortKey,
        order: z.enum(["asc", "desc"]),
      })