본문으로 건너뛰기

릴리스 노트

1년간 활발히 개발한 끝에 Zod 4가 안정 버전으로 출시되었습니다. 더 빠르고 가벼우며 tsc 효율이 높아졌고, 오랫동안 요청받아 온 여러 기능도 구현했습니다.

버전 관리

업그레이드하려면 다음 명령을 실행합니다.

npm install zod@^4.0.0

주요 변경 사항의 전체 목록은 마이그레이션 가이드를 참고하세요. 이 글에서는 새로운 기능과 개선 사항에 초점을 맞춥니다.

새로운 메이저 버전이 필요한 이유

Zod v3.0은 2021년 5월에 출시되었습니다. 당시 Zod는 GitHub 스타 2,700개와 주간 다운로드 60만 회를 기록했습니다. 현재는 스타 3만 7,800개, 주간 다운로드 3,100만 회를 기록하고 있습니다. 6주 전 베타 출시 당시의 2,300만 회에서 더 늘어난 수치입니다. 24번의 마이너 버전을 거치면서 Zod 3 코드베이스는 한계에 도달했고, 가장 많이 요청된 기능과 개선 사항을 구현하려면 주요 변경이 필요했습니다.

Zod 4는 Zod 3의 오래된 설계 한계를 한꺼번에 해결하여 오랫동안 요청받아 온 여러 기능과 큰 폭의 성능 향상을 위한 길을 열었습니다. Zod에서 추천을 가장 많이 받은 공개 이슈 10개 가운데 9개를 해결했습니다. 앞으로 오랫동안 새로운 기반이 되어 주기를 바랍니다.

새로운 내용을 빠르게 훑어보려면 목차를 확인하세요. 항목을 클릭하면 해당 섹션으로 이동합니다.

벤치마크

Zod 저장소에서 다음 벤치마크를 직접 실행할 수 있습니다.

$ git clone git@github.com:colinhacks/zod.git
$ cd zod
$ git switch v4
$ pnpm install

특정 벤치마크를 실행하려면 다음 명령을 사용합니다.

$ pnpm bench <name>

문자열 파싱 속도 14배 향상

$ pnpm bench string
runtime: node v22.13.0 (arm64-darwin)

benchmark time (avg) (min … max) p75 p99 p999
------------------------------------------------- -----------------------------
• z.string().parse
------------------------------------------------- -----------------------------
zod3 363 µs/iter (338 µs … 683 µs) 351 µs 467 µs 572 µs
zod4 24'674 ns/iter (21'083 ns … 235 µs) 24'209 ns 76'125 ns 120 µs

summary for z.string().parse
zod4
14.71x faster than zod3

배열 파싱 속도 7배 향상

$ pnpm bench array
runtime: node v22.13.0 (arm64-darwin)

benchmark time (avg) (min … max) p75 p99 p999
------------------------------------------------- -----------------------------
• z.array() parsing
------------------------------------------------- -----------------------------
zod3 147 µs/iter (137 µs … 767 µs) 140 µs 246 µs 520 µs
zod4 19'817 ns/iter (18'125 ns … 436 µs) 19'125 ns 44'500 ns 137 µs

summary for z.array() parsing
zod4
7.43x faster than zod3

객체 파싱 속도 6.5배 향상

이 명령은 Moltar 검증 라이브러리 벤치마크를 실행합니다.

$ pnpm bench object-moltar
benchmark time (avg) (min … max) p75 p99 p999
------------------------------------------------- -----------------------------
• z.object() safeParse
------------------------------------------------- -----------------------------
zod3 805 µs/iter (771 µs … 2'802 µs) 804 µs 928 µs 2'802 µs
zod4 124 µs/iter (118 µs … 1'236 µs) 119 µs 231 µs 329 µs

summary for z.object() safeParse
zod4
6.5x faster than zod3

tsc 인스턴스화 100배 감소

다음과 같은 간단한 파일을 살펴보겠습니다.

import * as z from "zod";

export const A = z.object({
a: z.string(),
b: z.string(),
c: z.string(),
d: z.string(),
e: z.string(),
});

export const B = A.extend({
f: z.string(),
g: z.string(),
h: z.string(),
});

이 파일을 tsc --extendedDiagnostics로 컴파일할 때 "zod/v3"을 사용하면 타입 인스턴스화가 25,000회 넘게 발생합니다. "zod/v4"에서는 약 175회만 발생합니다.

더 중요한 점은 Zod 4가 심각한 "인스턴스화 폭증"을 피하도록 ZodObject와 기타 스키마 클래스의 제네릭을 다시 설계하고 단순화했다는 것입니다. 예를 들어 이전에는 .extend().omit()을 반복해서 연결하면 컴파일러 문제가 발생했습니다.

import * as z from "zod";

export const a = z.object({
a: z.string(),
b: z.string(),
c: z.string(),
});

export const b = a.omit({
a: true,
b: true,
c: true,
});

export const c = b.extend({
a: z.string(),
b: z.string(),
c: z.string(),
});

export const d = c.omit({
a: true,
b: true,
c: true,
});

export const e = d.extend({
a: z.string(),
b: z.string(),
c: z.string(),
});

export const f = e.omit({
a: true,
b: true,
c: true,
});

export const g = f.extend({
a: z.string(),
b: z.string(),
c: z.string(),
});

export const h = g.omit({
a: true,
b: true,
c: true,
});

export const i = h.extend({
a: z.string(),
b: z.string(),
c: z.string(),
});

export const j = i.omit({
a: true,
b: true,
c: true,
});

export const k = j.extend({
a: z.string(),
b: z.string(),
c: z.string(),
});

export const l = k.omit({
a: true,
b: true,
c: true,
});

export const m = l.extend({
a: z.string(),
b: z.string(),
c: z.string(),
});

export const n = m.omit({
a: true,
b: true,
c: true,
});

export const o = n.extend({
a: z.string(),
b: z.string(),
c: z.string(),
});

export const p = o.omit({
a: true,
b: true,
c: true,
});

export const q = p.extend({
a: z.string(),
b: z.string(),
c: z.string(),
});

Zod 3에서는 컴파일에 4000ms가 걸렸고 .extend() 호출을 더 추가하면 "Possibly infinite" 오류가 발생했습니다. Zod 4에서는 400ms 만에 컴파일되어 10x 더 빠릅니다.

곧 출시될 tsgo 컴파일러와 함께 사용하면 훨씬 더 큰 스키마와 코드베이스에서도 Zod 4의 뛰어난 편집기 성능을 유지할 수 있습니다.

핵심 번들 크기 2배 감소

다음과 같은 간단한 스크립트를 살펴보겠습니다.

import * as z from "zod";

const schema = z.boolean();

schema.parse(true);

검증 코드로는 이보다 단순하기 어렵습니다. 단순한 경우에도 번들에 포함되는 코드인 핵심 번들 크기를 측정하기 위해 의도적으로 간단하게 만들었습니다. Zod 3과 Zod 4 각각에서 rollup으로 이 코드를 번들링한 뒤 최종 번들을 비교하겠습니다.

패키지gzip 번들
Zod 312.47kb
Zod 45.36kb

Zod 4의 핵심 번들은 약 57% 줄어 2.3배 작아졌습니다. 좋은 결과지만 훨씬 더 줄일 수 있습니다.

Zod Mini 소개

메서드가 많은 Zod API는 근본적으로 트리 셰이킹하기 어렵습니다. 간단한 z.boolean() 스크립트조차 .optional(), .array()처럼 사용하지 않은 여러 메서드의 구현을 함께 불러옵니다. 구현을 더 가볍게 만드는 것만으로는 한계가 있습니다. 이 문제를 해결하기 위해 Zod Mini가 등장했습니다.

npm install zod@^4.0.0

Zod Mini는 zod와 일대일로 대응하는 트리 셰이킹 가능한 함수형 API를 제공하는 Zod 변형입니다. Zod가 메서드를 사용하는 곳에서 Zod Mini는 일반적으로 래퍼 함수를 사용합니다.

import * as z from "zod/mini";

z.optional(z.string());

z.union([z.string(), z.number()]);

z.extend(z.object({ /* ... */ }), { age: z.number() });
import * as z from "zod";

z.string().optional();

z.string().or(z.number());

z.object({ /* ... */ }).extend({ age: z.number() });

모든 메서드가 사라진 것은 아닙니다. 파싱 메서드는 Zod와 Zod Mini에서 동일합니다.

import * as z from "zod/mini";

z.string().parse("asdf");
z.string().safeParse("asdf");
await z.string().parseAsync("asdf");
await z.string().safeParseAsync("asdf");

세부 검증을 추가하는 범용 .check() 메서드도 있습니다.

import * as z from "zod/mini";

z.array(z.number()).check(
z.minLength(5),
z.maxLength(10),
z.refine(arr => arr.includes(5))
);
import * as z from "zod";

z.array(z.number())
.min(5)
.max(10)
.refine(arr => arr.includes(5));

Zod Mini에서는 다음 최상위 세부 검증을 사용할 수 있습니다. 각각 어떤 Zod 메서드에 대응하는지는 이름만으로도 쉽게 알 수 있습니다.

import * as z from "zod/mini";

// custom checks
z.refine();

// first-class checks
z.lt(value);
z.lte(value); // alias: z.maximum()
z.gt(value);
z.gte(value); // alias: z.minimum()
z.positive();
z.negative();
z.nonpositive();
z.nonnegative();
z.multipleOf(value);
z.maxSize(value);
z.minSize(value);
z.size(value);
z.maxLength(value);
z.minLength(value);
z.length(value);
z.regex(regex);
z.lowercase();
z.uppercase();
z.includes(value);
z.startsWith(value);
z.endsWith(value);
z.property(key, schema); // for object schemas; check `input[key]` against `schema`
z.mime(value); // for file schemas (see below)

// overwrites (these *do not* change the inferred type!)
z.overwrite(value => newValue);
z.normalize();
z.trim();
z.toLowerCase();
z.toUpperCase();

이 함수형 API를 사용하면 번들러가 사용하지 않는 API를 더 쉽게 트리 셰이킹할 수 있습니다. 대부분의 사용 사례에는 여전히 일반 Zod를 권장하지만 번들 크기 제한이 특히 엄격한 프로젝트라면 Zod Mini를 고려하세요.

핵심 번들 크기 6.6배 감소

위 스크립트에서 "zod/mini"를 사용하도록 기존 "zod" 가져오기를 바꿨습니다.

import * as z from "zod/mini";

const schema = z.boolean();
schema.parse(false);

이 코드를 rollup으로 빌드하면 gzip으로 압축한 번들 크기가 1.88kb가 됩니다. zod@3과 비교해 핵심 번들 크기가 85%, 즉 6.6배 줄었습니다.

패키지gzip 번들
Zod 312.47kb
Zod 4 (일반)5.36kb
Zod 4 (미니)1.88kb

자세한 내용은 zod/mini 전용 문서 페이지를 참고하세요. 전체 API는 기존 문서 페이지에 함께 설명되어 있으며, API가 다른 코드 블록에는 "Zod""Zod Mini" 탭이 별도로 제공됩니다.

메타데이터

Zod 4는 스키마에 타입 안전한 메타데이터를 추가하는 새 시스템을 도입합니다. 메타데이터는 스키마 자체가 아니라 스키마와 타입이 지정된 메타데이터를 연결하는 "스키마 레지스트리"에 저장됩니다. z.registry()로 레지스트리를 만들려면 다음을 사용합니다.

import * as z from "zod";

const myRegistry = z.registry<{ title: string; description: string }>();

레지스트리에 스키마를 추가하려면 다음을 사용합니다.

const emailSchema = z.string().email();

myRegistry.add(emailSchema, { title: "Email address", description: "..." });
myRegistry.get(emailSchema);
// => { title: "Email address", ... }

편의를 위해 스키마의 .register() 메서드를 사용할 수도 있습니다.

emailSchema.register(myRegistry, { title: "Email address", description: "..." })
// => returns emailSchema

전역 레지스트리

Zod는 일반적인 JSON Schema 호환 메타데이터를 저장할 수 있는 전역 레지스트리 z.globalRegistry도 내보냅니다.

z.globalRegistry.add(z.string(), { 
id: "email_address",
title: "Email address",
description: "Provide your email",
examples: ["naomie@example.com"],
extraKey: "Additional properties are also allowed"
});

.meta()

스키마를 z.globalRegistry에 간편하게 추가하려면 .meta() 메서드를 사용합니다.

z.string().meta({ 
id: "email_address",
title: "Email address",
description: "Provide your email",
examples: ["naomie@example.com"],
// ...
});

JSON Schema 변환

Zod 4는 z.toJSONSchema()을 통한 자체 JSON Schema 변환 기능을 도입합니다.

import * as z from "zod";

const mySchema = z.object({name: z.string(), points: z.number()});

z.toJSONSchema(mySchema);
// => {
// type: "object",
// properties: {
// name: {type: "string"},
// points: {type: "number"},
// },
// required: ["name", "points"],
// }

z.globalRegistry의 모든 메타데이터는 JSON Schema 출력에 자동으로 포함됩니다.

const mySchema = z.object({
firstName: z.string().describe("Your first name"),
lastName: z.string().meta({ title: "last_name" }),
age: z.number().meta({ examples: [12, 99] }),
});

z.toJSONSchema(mySchema);
// => {
// type: 'object',
// properties: {
// firstName: { type: 'string', description: 'Your first name' },
// lastName: { type: 'string', title: 'last_name' },
// age: { type: 'number', examples: [ 12, 99 ] }
// },
// required: [ 'firstName', 'lastName', 'age' ]
// }

생성된 JSON Schema를 사용자 지정하는 방법은 JSON Schema 문서를 참고하세요.

재귀 객체

예상 밖의 성과였습니다. 수년간 이 문제를 해결하려고 시도한 끝에 Zod에서 재귀 객체 타입을 올바르게 추론하는 방법을 마침내 찾아냈습니다. 재귀 타입을 정의하려면 다음을 사용합니다.

const Category = z.object({
name: z.string(),
get subcategories(){
return z.array(Category)
}
});

type Category = z.infer<typeof Category>;
// { name: string; subcategories: Category[] }

상호 재귀 타입도 표현할 수 있습니다.

const User = z.object({
email: z.email(),
get posts(){
return z.array(Post)
}
});

const Post = z.object({
title: z.string(),
get author(){
return User
}
});

Zod 3의 재귀 타입 패턴과 달리 타입 캐스팅이 필요하지 않습니다. 결과 스키마는 일반 ZodObject 인스턴스이므로 모든 메서드를 사용할 수 있습니다.

Post.pick({ title: true })
Post.partial();
Post.extend({ publishDate: z.date() });

파일 스키마

File 인스턴스를 검증하려면 다음을 사용합니다.

const fileSchema = z.file();

fileSchema.min(10_000); // minimum .size (bytes)
fileSchema.max(1_000_000); // maximum .size (bytes)
fileSchema.mime(["image/png"]); // MIME type

국제화

Zod 4는 오류 메시지를 여러 언어로 전역 번역하는 새로운 locales API를 도입합니다.

import * as z from "zod";

// configure English locale (default)
z.config(z.locales.en());

지원되는 로케일의 전체 목록은 오류 사용자 지정에서 확인할 수 있습니다. 새 언어가 추가될 때마다 이 섹션의 목록도 업데이트됩니다.

오류 보기 좋게 출력하기

zod-validation-error 패키지의 인기는 오류를 보기 좋게 출력하는 공식 API에 대한 수요가 크다는 점을 보여줍니다. 현재 이 패키지를 사용하고 있다면 계속 사용해도 좋습니다.

이제 Zod가 제공하는 최상위 z.prettifyError 함수는 ZodError를 읽기 좋은 형식의 문자열로 변환합니다.

const myError = new z.ZodError([
{
code: 'unrecognized_keys',
keys: [ 'extraField' ],
path: [],
message: 'Unrecognized key: "extraField"'
},
{
expected: 'string',
code: 'invalid_type',
path: [ 'username' ],
message: 'Invalid input: expected string, received number'
},
{
origin: 'number',
code: 'too_small',
minimum: 0,
inclusive: true,
path: [ 'favoriteNumbers', 1 ],
message: 'Too small: expected number to be >=0'
}
]);

z.prettifyError(myError);

이 함수는 보기 좋게 출력할 수 있는 다음 여러 줄 문자열을 반환합니다.

✖ Unrecognized key: "extraField"
✖ Invalid input: expected string, received number
→ at username
✖ Invalid input: expected number, received string
→ at favoriteNumbers[1]

현재 출력 형식은 사용자 지정할 수 없지만 앞으로 바뀔 수 있습니다.

최상위 문자열 형식

이메일 등을 포함한 모든 "문자열 형식"이 z 모듈의 최상위 함수로 승격되었습니다. 더 간결하고 트리 셰이킹하기도 쉽습니다. 이에 대응하는 메서드(z.string().email() 등)도 계속 사용할 수 있지만 지원 중단되었으며 다음 메이저 버전에서 제거될 예정입니다.

z.email();
z.uuidv4();
z.uuidv6();
z.uuidv7();
z.ipv4();
z.ipv6();
z.cidrv4();
z.cidrv6();
z.url();
z.e164();
z.base64();
z.base64url();
z.jwt();
z.lowercase();
z.iso.date();
z.iso.datetime();
z.iso.duration();
z.iso.time();

사용자 지정 이메일 정규식

이제 z.email() API가 사용자 지정 정규식을 지원합니다. 유일한 표준 이메일 정규식은 없으며 애플리케이션마다 엄격도를 다르게 선택할 수 있습니다. 편의를 위해 Zod는 몇 가지 일반적인 정규식을 내보냅니다.

// Zod's default email regex (Gmail rules)
// see colinhacks.com/essays/reasonable-email-regex
z.email(); // z.regexes.email

// the regex used by browsers to validate input[type=email] fields
// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/email
z.email({ pattern: z.regexes.html5Email });

// the classic emailregex.com regex (RFC 5322)
z.email({ pattern: z.regexes.rfc5322Email });

// a loose regex that allows Unicode (good for intl emails)
z.email({ pattern: z.regexes.unicodeEmail });

템플릿 리터럴 타입

Zod 4는 z.templateLiteral()을 구현합니다. 템플릿 리터럴 타입은 이전에 표현할 수 없었던 TypeScript 타입 시스템의 주요 기능 중 하나입니다.

const hello = z.templateLiteral(["hello, ", z.string()]);
// `hello, ${string}`

const cssUnits = z.enum(["px", "em", "rem", "%"]);
const css = z.templateLiteral([z.number(), cssUnits]);
// `${number}px` | `${number}em` | `${number}rem` | `${number}%`

const email = z.templateLiteral([
z.string().min(1),
"@",
z.string().max(64),
]);
// `${string}@${string}` (the min/max refinements are enforced!)

문자열로 변환할 수 있는 모든 Zod 스키마 타입은 내부 정규식을 저장합니다. 여기에는 문자열, z.email() 같은 문자열 형식, 숫자, 불리언, bigint, 열거형, 리터럴, undefined/선택적 타입, null/nullable 타입, 기타 템플릿 리터럴이 포함됩니다. z.templateLiteral 생성자는 이를 하나의 통합 정규식으로 이어 붙이므로 z.email() 같은 문자열 형식은 올바르게 강제되지만 사용자 지정 세부 검증은 그렇지 않습니다.

자세한 내용은 템플릿 리터럴 문서를 참고하세요.

숫자 형식

고정 너비 정수 및 부동 소수점 타입을 표현하는 새로운 숫자 "형식"이 추가되었습니다. 이 형식은 최솟값과 최댓값 경계를 포함하는 적절한 제약 조건이 이미 적용된 ZodNumber 인스턴스를 반환합니다.

z.int();      // [Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER]
z.float32(); // [-3.4028234663852886e38, 3.4028234663852886e38]
z.float64(); // [-1.7976931348623157e308, 1.7976931348623157e308]
z.int32(); // [-2147483648, 2147483647]
z.uint32(); // [0, 4294967295]

마찬가지로 다음 bigint 숫자 형식도 추가되었습니다. 이 정수 타입은 JavaScript의 number로 안전하게 표현할 수 있는 범위를 넘으므로 최솟값과 최댓값 경계를 포함하는 적절한 제약 조건이 이미 적용된 ZodBigInt 인스턴스를 반환합니다.

z.int64();    // [-9223372036854775808n, 9223372036854775807n]
z.uint64(); // [0n, 18446744073709551615n]

문자열 불리언

기존 z.coerce.boolean() API는 매우 단순합니다. 거짓으로 평가되는 값(false, undefined, null, 0, "", NaN 등)은 false가 되고, 참으로 평가되는 값은 true가 됩니다.

이 API는 여전히 유용하며 동작 방식도 다른 z.coerce API와 일치합니다. 하지만 일부 사용자는 더 정교한 "환경 변수 스타일" 불리언 강제 변환을 요청했습니다. 이를 지원하기 위해 Zod 4는 z.stringbool()을 도입합니다.

const strbool = z.stringbool();

strbool.parse("true") // => true
strbool.parse("1") // => true
strbool.parse("yes") // => true
strbool.parse("on") // => true
strbool.parse("y") // => true
strbool.parse("enabled") // => true

strbool.parse("false"); // => false
strbool.parse("0"); // => false
strbool.parse("no"); // => false
strbool.parse("off"); // => false
strbool.parse("n"); // => false
strbool.parse("disabled"); // => false

strbool.parse(/* anything else */); // ZodError<[{ code: "invalid_value" }]>

참과 거짓으로 평가할 값을 사용자 지정하려면 다음을 사용합니다.

z.stringbool({
truthy: ["yes", "true"],
falsy: ["no", "false"]
})

자세한 내용은 z.stringbool() 문서를 참고하세요.

단순해진 오류 사용자 지정

Zod 4의 주요 변경 사항 대부분은 오류 사용자 지정 API와 관련되어 있습니다. Zod 3에서는 다소 복잡했지만 Zod 4에서는 훨씬 깔끔해졌으므로 여기서 따로 살펴볼 가치가 있습니다.

요약하면 오류 사용자 지정 API는 이제 하나의 통합 error 매개변수로 정리되었습니다. 기존 API와의 대응 관계는 다음과 같습니다.

messageerror로 바꿉니다. message 매개변수도 하위 호환성을 위해 계속 지원되지만 지원 중단 상태입니다.

- z.string().min(5, { message: "Too short." });
+ z.string().min(5, { error: "Too short." });

invalid_type_errorrequired_error를 함수 문법을 사용하는 error로 바꿉니다.

// Zod 3
- z.string({
- required_error: "This field is required"
- invalid_type_error: "Not a string",
- });

// Zod 4
+ z.string({ error: (issue) => issue.input === undefined ?
+ "This field is required" :
+ "Not a string"
+ });

errorMap을 함수 문법을 사용하는 error로 바꿉니다.

// Zod 3 
- z.string({
- errorMap: (issue, ctx) => {
- if (issue.code === "too_small") {
- return { message: `Value must be >${issue.minimum}` };
- }
- return { message: ctx.defaultError };
- },
- });

// Zod 4
+ z.string({
+ error: (issue) => {
+ if (issue.code === "too_small") {
+ return `Value must be >${issue.minimum}`
+ }
+ },
+ });

향상된 z.discriminatedUnion()

이제 판별 유니온은 유니온과 파이프를 비롯하여 이전에 지원하지 않던 여러 스키마 타입을 지원합니다.

const MyResult = z.discriminatedUnion("status", [
// simple literal
z.object({ status: z.literal("aaa"), data: z.string() }),
// union discriminator
z.object({ status: z.union([z.literal("bbb"), z.literal("ccc")]) }),
// pipe discriminator
z.object({ status: z.literal("fail").transform(val => val.toUpperCase()) }),
]);

무엇보다 판별 유니온을 이제 조합할 수 있습니다. 하나의 판별 유니온을 다른 판별 유니온의 멤버로 사용할 수 있습니다.

const BaseError = z.object({ status: z.literal("failed"), message: z.string() });

const MyResult = z.discriminatedUnion("status", [
z.object({ status: z.literal("success"), data: z.string() }),
z.discriminatedUnion("code", [
BaseError.extend({ code: z.literal(400) }),
BaseError.extend({ code: z.literal(401) }),
BaseError.extend({ code: z.literal(500) })
])
]);

z.literal()에서 여러 값 사용하기

이제 z.literal() API는 여러 값을 선택적으로 받을 수 있습니다.

const httpCodes = z.literal([ 200, 201, 202, 204, 206, 207, 208, 226 ]);

// previously in Zod 3:
const httpCodes = z.union([
z.literal(200),
z.literal(201),
z.literal(202),
z.literal(204),
z.literal(206),
z.literal(207),
z.literal(208),
z.literal(226)
]);

스키마 내부에 저장되는 세부 검증

Zod 3에서는 세부 검증이 원본 스키마를 감싸는 ZodEffects 클래스에 저장되었습니다. 이 때문에 .refine().min() 같은 다른 스키마 메서드를 번갈아 연결할 수 없어 불편했습니다.

z.string()
.refine(val => val.includes("@"))
.min(5);
// ^ ❌ Property 'min' does not exist on type ZodEffects<ZodString, string, string>

Zod 4에서는 세부 검증을 스키마 자체에 저장하므로 위 코드가 예상대로 작동합니다.

z.string()
.refine(val => val.includes("@"))
.min(5); // ✅

.overwrite()

.transform() 메서드는 매우 유용하지만 한 가지 큰 단점이 있습니다. 런타임에 출력 타입을 더 이상 분석할 수 없습니다. 변환 함수는 무엇이든 반환할 수 있는 블랙박스이므로 스키마를 JSON Schema로 안전하게 변환할 방법이 없습니다.

const Squared = z.number().transform(val => val ** 2);
// => ZodPipe<ZodNumber, ZodTransform>

Zod 4는 추론된 타입을 변경하지 않는 변환을 표현하는 새로운 .overwrite() 메서드를 도입합니다. .transform()과 달리 이 메서드는 원본 클래스의 인스턴스를 반환합니다. 덮어쓰기 함수는 세부 검증으로 저장되므로 추론된 타입을 변경하지 않으며 변경할 수도 없습니다.

z.number().overwrite(val => val ** 2).max(100);
// => ZodNumber

기존 .trim(), .toLowerCase(), .toUpperCase() 메서드는 .overwrite()를 사용하도록 다시 구현되었습니다.

확장 가능한 기반: zod/v4/core

대부분의 Zod 사용자에게 직접 관련되지는 않지만 짚고 넘어갈 가치가 있습니다. Zod Mini가 추가되면서 Zod와 Zod Mini가 공유하는 핵심 기능을 담은 공용 하위 패키지 zod/v4/core가 필요해졌습니다.

처음에는 이 방식에 거부감이 있었지만, 지금은 Zod 4의 가장 중요한 기능 가운데 하나라고 생각합니다. Zod를 단순한 라이브러리에서 다른 라이브러리에 손쉽게 내장할 수 있는 빠른 검증 "기반 계층"으로 발전시켰습니다.

스키마 라이브러리를 만든다면 Zod와 Zod Mini의 구현을 참고하여 zod/v4/core를 토대로 확장하는 방법을 확인하세요. 도움이 필요하거나 의견이 있다면 언제든 GitHub Discussions 또는 X/Bluesky로 연락해 주세요.

마무리

Zod Mini 같은 주요 기능의 설계 과정을 설명하는 글을 추가로 연재할 계획입니다. 새 글이 게시되면 이 섹션을 업데이트하겠습니다.

라이브러리 개발자를 위해 Zod 위에 라이브러리를 구축하는 모범 사례를 설명하는 라이브러리 개발자 가이드도 마련했습니다. Zod 3과 Zod 4(Mini 포함)를 동시에 지원하는 방법에 관한 일반적인 질문에 답합니다.

pnpm upgrade zod@latest

즐겁게 파싱하세요!
— Colin McDonnell @colinhacks