99

최근 30분간 동시 방문자 수를 표시합니다. (~)

최고 동시 방문자 수 -
어제: 0명 / 오늘: 0명

Zod 핵심 정리 - 런타임 타입 검증

Zod 핵심 정리 - 런타임 타입 검증

info

이 글은 Zod 4.1.5 버전을 기준으로 작성되었습니다.

타입스크립트는 컴파일 단계에서 강력한 타입 검사를 제공하지만, 런타임 단계에서의 타입 안전성까지 보장하지는 못합니다.
Zod는 런타임에서 데이터의 유효성을 검증하는 라이브러리로, 타입스크립트의 컴파일 타입 검사와 함께 사용해 더욱 강력한 타입 안전성을 보장할 수 있습니다.
물론 자바스크립트에서도 사용할 수 있습니다.

# 설치 및 기본 사용

다음과 같이 설치합니다.

BASH
content_copy
1
npm i zod

설치 후 간단한 테스트를 위해 다음과 같이 사용자 객체 데이터를 준비합니다.

/user.ts
TS
content_copy
1
2
3
4
5
// 사용자 데이터 export const userData = { name: 'Neo', age: 22 }

다음으로 사용자 데이터를 검증하기 위한 스키마(Schema)를 정의합니다.
Zod의 z 객체에서 .object(), .string(), .number() 등의 다양한 스키마 메소드를 사용할 수 있습니다.
그리고 원하는 데이터 스키마를 정의했다면, 그 스키마에서 .parse() 등의 메소드를 사용해 사용자 데이터를 인수로 전달합니다.
그러면 런타임에서 사용자 데이터가 정의한 스키마와 일치하는지 유효성을 확인(검증)합니다.
만약 데이터가 스키마와 일치하지 않으면 런타임에서 에러가 발생합니다.

/main.js
JS
content_copy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { z } from 'zod' import { userData } from './user.js' // 스키마 정의 const userSchema = z.object({ name: z.string(), age: z.number() }) // 데이터 검증 및 에러 처리 try { userSchema.parse(userData) } catch (error) { if (error instanceof z.ZodError) { console.error(error.issues) } }
JS 코드

타입스크립트를 사용하는 경우 정의한 스키마의 타입이 필요할 수 있습니다.
z.infer을 통해 타입을 추론(Inference)하고 그 결과로 스키마의 타입을 얻을 수 있습니다.
그리고 타입스크립트의 satisfies 키워드를 사용하면 데이터가 타입을 만족하는지 쉽게 확인할 수 있습니다.

/main.ts
TS
content_copy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { z } from 'zod' import { userData } from './user' // 스키마 및 타입 정의 const userSchema = z.object({ name: z.string(), age: z.number() }) type UserSchema = z.infer<typeof userSchema> // 데이터 검증 및 에러 처리 try { userSchema.parse(userData satisfies UserSchema) // 데이터의 타입 만족 확인 } catch (error) { if (error instanceof z.ZodError) { console.error(error.issues) // [...] } }
TS 코드

# 스키마 검증

스키마 객체에서 사용할 수 있는 여러 검증 메소드를 통해, 제공 데이터가 스키마와 일치하는지 확인할 수 있습니다.

warning

아래에서 살펴볼 .parse(), .safeParse() 등의 검증 메소드가 성공 후 반환하는 데이터는 깊게 복사된(Deep Copy) 사본입니다.
만약 중첩된 참조형의 불변성(Immutability)이 중요한 경우, 반환된 데이터(사본)를 사용하지 않는 것이 좋습니다.

# .parse()

.parse() 메소드는 데이터를 검증해, 성공하면 그 데이터를 반환하고 실패하면 에러를 던집니다.
따라서 try/catch 구문으로 에러를 처리해야 할 수 있습니다.
에러가 발생한 경우, error 객체의 issues 배열 속성을 조회해 다양한 에러 정보를 확인할 수 있습니다.

info

에러와 관련된 내용은 에러 처리 파트에서 더 자세하게 살펴봅니다.

/main.ts
TS
content_copy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { z } from 'zod' import { userData } from './user' // 정의 const userSchema = z.object({ name: z.string(), age: z.number() }) type User = z.infer<typeof userSchema> try { // 검증 const result = userSchema.parse(userData satisfies User) // 성공 console.log(result) // { name: 'Neo', age: 22 } } catch (error) { // 실패 if (error instanceof z.ZodError) { console.error(error.issues) // [...] } }

# .safeParse()

.safeParse() 메소드는 데이터를 검증해, 성공이나 실패 정보가 포함된 결과 객체를 반환합니다.
성공하면 결과(result)의 data 속성으로 데이터를 조회하고, 실패하면 error 속성으로 에러 정보를 처리할 수 있습니다.
.safeParse() 메소드는 검증이 실패해도 에러를 던지지 않으므로 try/catch 구문을 사용할 필요가 없습니다.

/main.ts
TS
content_copy
1
2
3
4
5
6
7
8
9
10
11
12
// ... // 검증 const result = userSchema.safeParse(userData satisfies User) // 성공 console.log(result.data) // { name: 'Neo', age: 22 } // 실패 if (result.error instanceof z.ZodError) { console.error(error.issues) // [...] }

# .parseAsync()

.parseAsync() 메소드는 앞서 살펴본 .parse() 메소드와 비슷하지만, await 키워드를 사용해 비동기로 검증합니다.
스키마를 정의할 때 비동기 처리가 필요한 경우 사용합니다.

다음 예제에서 isNameExists 함수는 존재하는 사용자 이름이 있는지 비동기로 확인합니다.
단순히 1초 뒤에 존재 여부를 반환합니다.
resolve 호출의 인수를 true/false로 지정해 테스트할 수도 있습니다.

/user.ts
TS
content_copy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 사용자 데이터 export const userData = { name: 'Neo', age: 22 } // 존재하는 이름인지 확인하는 비동기 함수 예시 export function isNameExists(name: string) { return new Promise(resolve => { // fetch(`https://api.heropy.dev/v0/users/exists?name=${name}`) // .then(res => res.json()) // .then(name => setTimeout(() => resolve(Boolean(name)), 1000)) setTimeout(() => resolve(Boolean(name)), 1000) }) }

다음 예제에서는 사용자 이름(name)이 문자 데이터이고 추가로 isNameExists 함수로 이미 존재하는 이름인지 검증합니다.
이때 isNameExists 함수는 Promise 인스턴스를 반환하기 때문에, parseAsync 메소드를 사용해야 검증을 기다릴 수 있습니다.

info

스키마를 정의할 때 refine 메소드를 사용하면 커스텀 검증을 위한 콜백 함수를 추가할 수 있습니다.
콜백 함수는 불린 데이터를 반환해야 합니다.

/main.ts
TS
content_copy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import { z } from 'zod' import { userData, isNameExists } from './user' const userSchema = z.object({ name: z .string() .refine( val => isNameExists(val), { error: '이미 사용 중인 이름입니다.' } ), age: z.number() }) type User = z.infer<typeof userSchema> try { // 검증 const res = await userSchema.parseAsync(userData satisfies User) // 성공 console.log(res) // { name: 'Neo', age: 22 } } catch (error) { // 실패 if (error instanceof z.ZodError) { console.error(error.issues[0]) // { code: 'custom', path: ['name'], message: '이미 사용 중인 이름입니다.' } } }

# .safeParseAsync()

.safeParseAsync() 메소드는 앞서 살펴본 .safeParse()와 같이 try/catch 구문 없이 결과 객체를 반환하고, .parseAsync()처럼 await 키워드를 사용해 비동기로 호출합니다.

/main.ts
TS
content_copy
1
2
3
4
5
6
7
8
9
10
11
12
// ... // 검증 const result = await userSchema.safeParseAsync(userData satisfies User) // 성공 console.log(result.data) // { name: 'Neo', age: 22 } // 실패 if (result.error instanceof z.ZodError) { console.error(result.error.issues[0]) // { code: 'custom', path: ['name'], message: '이미 사용 중인 이름입니다.' } }

# 스키마 정의

데이터 유효성을 검사하기 위해서는 우선 스키마를 정의해야 합니다.
스키마는 간단한 원시형부터 복잡한 중첩 객체 등 다양한 타입을 나타낼 수 있습니다.

# 문자 타입

TS
content_copy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
// 문자 z.string() z.string().min(2) // 최소 글자수 z.string().max(10) // 최대 글자수 z.string().length(7) // 정확한 글자수 z.string().regex(/^[a-z]+$/) // 정규 표현식 z.string().startsWith('A') // 문자로 시작 z.string().endsWith('Z') // 문자로 끝 z.string().includes('abc') // 문자를 포함 z.string().uppercase() // 대문자 z.string().lowercase() // 소문자 // 템플릿 리터럴 z.templateLiteral(['Hello ', z.string().min(8), '?!']) // => `Hello ${'template'}?!` // => `Hello ${'world'}?!` // ❌ // 이메일 z.email() // => 'thesecon@gmail.com' z.email({ pattern: /^\w+@\w+\.\w+$/ }) // 정규표현식 z.email({ pattern: z.regexes.email }) // 기본 이메일 z.email({ pattern: z.regexes.html5Email }) // HTML5 이메일 z.email({ pattern: z.regexes.rfc5322Email }) // RFC 5322 이메일 z.email({ pattern: z.regexes.unicodeEmail }) // 유니코드 이메일 // URL z.url() // => 'https://heropy.dev', 'http://localhost:3000', 'mailto:thesecon@gmail.com' // 호스트 이름 z.hostname() // => 'example.com', 'localhost', '127.0.0.1' // 이모지 z.emoji() // => '👍' // Base64 z.base64() // 표준 버전 z.base64url() // URL 안전 버전 // => 'SGVsbG8gd29ybGQ=' // => 'data:text/plain;base64,SGVsbG8gd29ybGQ=' // ❌ // JSON Web Token z.jwt() // => 'eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.e30.' // UUID z.uuid() z.uuid({ version: 'v4' }) // v1~8 버전 지정 z.uuidv4() // v4 z.uuidv6() // v6 z.uuidv7() // v7 // => '123e4567-e89b-12d3-a456-426614174000' // 랜덤 문자(21자 이상) z.nanoid() // => 'abcdefghij12345678901' // 카드키 식별자(9자 이상) z.cuid() // => 'c12345678' // 128비트 식별자(26자) z.ulid() // => '01H7Z8K4P2M9N3Q59V0W1X2Y3Z' // IPv4, IPv6 z.ipv4() // => '192.168.1.1' z.ipv6() // => '2001:0db8:85a3:0000:0000:8a2e:0370:7334' z.cidrv4() // => '192.168.1.0/24' z.cidrv6() // => '2001:0db8:85a3:0000:0000:8a2e:0370:7334/64' // Date z.date() // => new Date('2025-12-16') z.date().min(new Date('2025-01-01')).max(new Date('2025-12-31')) // 날짜 범위 제한 // => new Date('2025-12-16') // => new Date('2026-04-03') // ❌ z.iso.date() // ISO 8601 날짜 z.iso.time() // ISO 8601 시간 z.iso.datetime() // ISO 8601 날짜와 시간 z.iso.duration() // ISO 8601 기간(Period) // =>'2025-04-03' // =>'12:00', '12:00:00', '12:00:00.000' // =>'2025-04-03T12:00:00Z' // =>'P1Y2M3DT4H5M6S'

# 숫자 타입

TS
content_copy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 숫자 z.number() z.number().gt(5) // 보다 큰 숫자 z.number().gte(5) // 보다 크거나 같은 숫자 z.number().min(5) // 최소 숫자, `gte`와 같음 z.number().lt(5) // 보다 작은 숫자 z.number().lte(5) // 보다 작거나 같은 숫자 z.number().max(5) // 최대 숫자, `lte`와 같음 z.number().positive() // 양수 z.number().nonpositive() // 양수가 아닌 숫자 z.number().negative() // 음수 z.number().nonnegative() // 음수가 아닌 숫자 z.number().multipleOf(2) // 배수 z.number().step(2) // 배수, `multipleOf`와 같음 // 안전한 정수 z.int() // => ~9007199254740991 // 32비트 정수 z.int32() // => ~2147483647 // 큰 정수 z.bigint() // => 1234n z.int64() // 64비트 정수, `bigint`와 같음 // 부동소수점 z.float32() // 32비트, 약 7자리 단정밀도 z.float64() // 64비트, 약 15자리 배정밀도 // Not a Number z.nan() // => NaN

# 기타 원시 타입

TS
content_copy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// 불린 z.boolean() // 문자 불린 const stb1 = z.stringbool() stb1.parse('true') // true stb1.parse('1') // true stb1.parse('yes') // true stb1.parse('false') // false stb1.parse('0') // false // `stringbool`의 기본값은 다음과 같음 z.stringbool({ truthy: ['true', '1', 'yes', 'on', 'y', 'enabled'], // 참 문자 목록 falsy: ['false', '0', 'no', 'off', 'n', 'disabled'], // 거짓 문자 목록 case: 'insensitive' // 대소문자 구분 없음 }) const stb2 = z.stringbool({ truthy: ['Yes', 'Y'], falsy: ['No', 'N'], case: 'sensitive' // 대소문자 구분 }) // 사용자 정의 stb2.parse('Yes') // true stb2.parse('yes') // true stb2.parse('N') // false // 심볼 z.symbol() // => Symbol('id') // Null, Undefined z.null() // => null z.undefined() // => undefined z.void() // => void, undefined와 같음 // Undefined 가능 - z.optional(스키마) z.optional(z.number()) // => 123, undefined // => null // ❌ // Null 가능 - z.nullable(스키마) z.nullable(z.number()) // => 123, null // => undefined // ❌ // Null 또는 Undefined 가능 - z.nullish(스키마) z.nullish(z.number()) // => 123, null, undefined

# 참조 타입

TS
content_copy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
// 객체 - 알 수 없는 속성 제외 - z.object(객체_스키마) const obj1 = z.object({ name: z.string(), age: z.number() }) obj1.parse({ name: 'Neo' }) // ❌ obj1.parse({ name: 'Neo', age: 22 }) // { name: 'Neo', age: 22 } obj1.parse({ name: 'Neo', age: 22, isValid: true }) // { name: 'Neo', age: 22 }, isValid 속성 제외됨 // 객체 - 알 수 없는 속성 에러 - z.strictObject(객체_스키마) const obj2 = z.strictObject({ name: z.string(), age: z.number() }) obj2.parse({ name: 'Neo' }) // ❌ obj2.parse({ name: 'Neo', age: 22 }) // { name: 'Neo', age: 22 } obj2.parse({ name: 'Neo', age: 22, isValid: true }) // ❌ // 객체 - 알 수 없는 속성 허용 - z.looseObject(객체_스키마) const obj3 = z.looseObject({ name: z.string(), age: z.number() }) obj3.parse({ name: 'Neo' }) // ❌ obj3.parse({ name: 'Neo', age: 22 }) // { name: 'Neo', age: 22 } obj3.parse({ name: 'Neo', age: 22, isValid: true }) // { name: 'Neo', age: 22, isValid: true }, isValid 속성 포함됨 // 객체 관련 메소드 const obj4 = z.object({ name: z.string(), age: z.number().optional() // 선택적 속성으로 정의 }) obj4.catchAll(z.boolean()) // 허용하려는 알 수 없는 추가 속성들의 스키마 정의 // => { isValid: z.boolean(), hasEmail: z.boolean() } obj4.shape.age // 속성의 스키마 // z.number().optional() obj4.keyof() // 객체의 모든 속성 이름 스키마(Enum) // z.enum(['name', 'age']) obj4.extend({ isValid: z.boolean() }) // 병합된 새로운 객체 스키마 // z.object({ name: z.string(), age: z.number().optional(), isValid: z.boolean() }) obj4.pick({ name: true }) // 선택된 속성의 객체 스키마 // z.object({ name: z.string() }) obj4.omit({ name: true }) // 제외된 속성의 객체 스키마 // z.object({ age: z.number().optional() }) obj4.partial() // 모든 속성이 선택적인 새로운 객체 스키마 // z.object({ name: z.string().optional(), age: z.number().optional() }) obj4.required() // 모든 속성이 필수적인 새로운 객체 스키마 // z.object({ name: z.string(), age: z.number() }) // 객체 - Key-Value - z.record(키_스키마, 값_스키마) z.record(z.string(), z.number()) // => { a: 1, b: 2, c: 3 } // => { a: '1', b: '2', c: '3' } // ❌ // => {} // ❌ // 키 타입 제한 z.record( z.union([z.string(), z.number(), z.symbol()]), z.number() ) // => { abc: 1, 123: 2, [Symbol('id')]: 3 } // 키 이름 제한 z.record( z.enum(['name', 'age']), z.number() ) // => { name: 1, age: 2 } // => { name: 1, age: 2, isValid: 3 } // ❌ // 객체 - 선택적 속성의 Key-Value - z.partialRecord(키_스키마, 값_스키마) z.partialRecord(z.string(), z.number()) // => {} // => { a: 1 } // => { a: 1, b: 2 } // => { a: 1, b: 2, c: 3 } // 배열 - z.array(아이템_스키마) z.array(z.string()) // => ['A', 'B', 'C', 'D'] z.array(z.number()) // => [1, 2, 3, 4] z.array(z.union([z.string(), z.number()])) // => ['A', 1, 'B', 2, 'C', 3, 'D', 4] z.array(z.string()).min(3) // 최소 아이템 수 z.array(z.string()).max(2) // 최대 아이템 수 z.array(z.number()).unwrap() // 배열의 아이템 스키마 확인 // 튜플 - z.tuple(스키마_배열) z.tuple([z.string(), z.number()]) // => ['Neo', 22]

# 기타 타입

TS
content_copy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// 리터럴 - z.literal(값) z.literal('Hello') z.literal(123) z.literal(true) // 모든 타입 z.any() // 알 수 없는 타입 z.unknown() // 불가능한 타입 z.never() // 열거형 - z.enum(문자_배열) z.enum(['a', 'b']) // => 'a' // => 'b' // => 'c' // ❌ // 유니온 - z.union(스키마_배열) z.union([z.string(), z.number()]) // => 'Hello' // => 123 // => true // ❌ // 식별 가능 유니온 - z.discriminatedUnion(식별_키, 스키마_배열) const dis1 = z.discriminatedUnion('status', [ z.object({ status: z.literal('success'), data: z.string() }), z.object({ status: z.literal('failed'), error: z.string() }) ]) type DU = z.infer<typeof dis1> // DU 타입은 다음과 같음 // { status: 'success'; data: string } | { status: 'failed'; error: string } function getData(res: DU) { dis1.parse(res) return res.status === 'success' ? res.data : res.error } getData({ status: 'success', data: 'Hello~' }) // 'Hello~' getData({ status: 'failed', error: 'Error~' }) // 'Error~' // 인터섹션 - z.intersection(스키마_배열) const obj1 = z.object({ status: z.string(), data: z.string() }) const obj2 = z.object({ status: z.string(), error: z.string() }) const int1 = z.intersection(obj1, obj2) int1.parse({ status: 'success', data: 'Hello!', error: 'Error!' }) // JSON z.json() // => 'Hello' // => 123 // => { name: 'Neo', age: 22 } // => undefined // ❌ // => function () {} // ❌ // => new Date() // ❌

# 클래스 타입

TS
content_copy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class User { constructor(public name: string, public age: number) {} } // 인스턴스 - z.instanceof(클래스) const user = new User('Neo', 22) const date = new Date() const ins1 = z.instanceof(User) ins1.parse(user) ins1.parse(date) // ❌ // 속성 - z.property(속성명, 스키마) const pro1 = z.instanceof(User).check(z.property('age', z.number().min(18))) pro1.parse(new User('Neo', 22)) pro1.parse(new User('Neo', 17)) // ❌ // Map - z.map(키_스미카, 값_스키마) const map = new Map() map.set(200, 'OK') map.set(404, 'Not Found') map.set(500, 'Internal Server Error') const ma1 = z.map(z.number(), z.string()) ma1.parse(map) // Set - z.set(값_스키마) const set = new Set() set.add({ x: 123 }) const se1 = z.set(z.object({ x: z.number() })) se1.parse(set) // File z.file() z.file().min(1024) // 최소 크기(바이트) z.file().max(1024) // 최대 크기(바이트) z.file().mime('image/png') // MIME 타입 const file = new File([], 'favicon.png') const fil1 = z.file() fil1.parse(file)

# 커스텀 타입

TS
content_copy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
// 스키마 전처리 - z.preprocess(전처리_함수, 스키마) const pre1 = z.preprocess(val => { return typeof val === 'string' ? val.toUpperCase() : val }, z.string()) pre1.parse('abc') // 'ABC' pre1.parse(123) // ❌ // 스키마 시용자 정의 - z.custom(검증_함수) const cus1 = z.custom(val => { return typeof val === 'string' ? /[A-Z]/.test(val) : false }) cus1.parse('ABC') // 'ABC' cus1.parse(123) // ❌ // 스키마 변환 - z.transform(변환_함수) const tra1 = z.transform(val => { return typeof val === 'string' ? val.toUpperCase() : val }) tra1.parse('abc') // 'ABC' tra1.parse(123) // 123 // 스키마.transform(변환_함수) const tra2 = z.string().transform(val => val.toUpperCase()) tra2.parse('abc') // 'ABC' tra2.parse(123) // ❌ // 스미카 연결 - z.pipe(in_스키마, out_스키마) const pip1 = z.pipe( z.string(), z.transform(val => val.length) ) pip1.parse('abc') // 3 pip1.parse(123) // ❌ // in_스키마.pipe(out_스키마) const pip2 = z.string().pipe(z.transform(val => val.length)) pip2.parse('abc') // 3 pip2.parse(123) // ❌ // 스키마 정제 - 스키마.refine(정제_함수, 옵션) const ref1 = z .string() .refine(val => val.length < 4, { error: '글자가 너무 길어요!' }) .refine(val => val.toUpperCase().includes('A'), { error: `'A' 혹은 'a'가 포함되어야 해요!` }) ref1.parse('abc') // 'abc' ref1.parse('xyz123') // ❌ // 스키마 정제 - 스키마.superRefine(정제_함수) const sup1 = z.string().superRefine((val, ctx) => { if (val.length > 3) { ctx.addIssue({ code: 'custom', message: 'Too many letters!' }) } if (!val.toUpperCase().includes('A')) { ctx.addIssue({ code: 'custom', message: `must include the letter 'A' or 'a'.` }) } }) sup1.parse('abc') // 'abc' sup1.parse('xyz123') // ❌ // 스키마 확인 - 스키마.check(확인_함수) const che1 = z.string().check(ctx => { if (ctx.value.length > 3) { ctx.issues.push({ code: 'custom', input: ctx.value, message: 'Too many letters!' }) } if (!ctx.value.toUpperCase().includes('A')) { ctx.issues.push({ code: 'custom', input: ctx.value, message: `must include the letter 'A' or 'a'.` }) } }) che1.parse('abc') // 'abc' che1.parse('xyz123') // ❌ // 기본값 - 스키마.default(기본값) const def1 = z .number() .transform(val => val + 10) .default(0) def1.parse(2) // 12 def1.parse(undefined) // 0 // 기본값 전처리 - 스키마.prefault(기본값) const pre1 = z .number() .transform(val => val + 10) .prefault(0) pre1.parse(2) // 12 pre1.parse(undefined) // 10 // 에러 시 기본값 const cat1 = z.number().catch(0) cat1.parse('Hello~') // 0 const cat2 = z.number().catch(ctx => { console.log(ctx.error.issues[0].message) // 'Invalid input: expected number, received string' return 0 }) cat2.parse('Hello~') // 0 // 읽기 전용 const arr = z.array(z.number()).readonly() const res = schema.parse([1, 2, 3]) res // [1, 2, 3] res.push(4) // ❌ // Error! ts(2339) // 브랜드 타입 const id = z.email().brand<'ID'>() const pw = z.string().min(8).max(20).brand<'PW'>() type Id = z.infer<typeof id> type Pw = z.infer<typeof pw> const res1: Pw = id.parse('thesecon@gmail.com') // Error! ts(2322) const res2: Id = pw.parse('qwer1234') // Error! ts(2322) res1 // 'thesecon@gmail.com' res2 // 'qwer1234'

# 에러 처리

위에서 살펴본 것처럼, .parse(), .safeParse() 등의 검증 메소드를 사용하고 데이터 검증에 실패하면 에러를 확인할 수 있습니다.
실패 결과의 에러 객체는 z.ZodError의 인스턴스이며, 에러 객체의 issues 속성에는 배열의 아이템으로 여러 이슈 객체가 포함됩니다.
이슈 객체는 expected, code 등의 속성을 가지고 있으며, 특히 message 속성을 통해 에러가 발생한 이유를 확인할 수 있습니다.

TS
content_copy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// .parse() + try/catch try { z.number().parse('Hello~') // ❌ } catch (error) { if (error instanceof z.ZodError) { console.log(error.issues) } } // .safeParse() const result = z.number().safeParse('Hello~') // ❌ if (result.error instanceof z.ZodError) { console.log(result.error.issues) } // 이슈 객체 구조 예시, error.issues[0] // => { // expected: 'number', // 기대되는 타입 // code: 'invalid_type', // 에러 코드 // path: ['age'], // 에러 발생 경로(속성) // message: 'Invalid input: expected number, received string' // 에러 메시지 // }

Zod는 국제화를 지원합니다.
따라서 기본 에러 메시지를 한글로 출력할 수도 있습니다.

그리고 사용자 전용 에러 메시지를 스키마나 검증 메소드에 직접 추가할 수 있습니다.
참고로 사용자 전용 에러 메시지는 검증 메소드보다 스키마에 적용하는 것이 더 우선합니다.

TS
content_copy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { z } from 'zod' import { ko } from 'zod/locales' z.config(ko()) // 한국어 구성 try { z.number().parse('Hello~') // '잘못된 입력: 예상 타입은 number, 받은 타입은 string입니다' z.number('높은 우선순위').parse('Hello~') z.number({ error: '높은 우선순위' }).parse('Hello~') z.number({ error: issue => '높은 우선순위' }).parse('Hello~') z.number({ error: issue => '높은 우선순위' }).parse('Hello~', { error: issue => '낮은 우선순위' }) // '높은 우선순위' z.number().parse('Hello~', { error: issue => '낮은 우선순위' }) // '낮은 우선순위' } catch (error) { if (error instanceof z.ZodError) { console.log(error.issues[0].message) } }

Zod는 에러 정보를 더 유연하게 표시하기 위해서, .treeifyError() 등의 유틸리티 메소드를 제공합니다.

멤버 설명
error.issues 오류 객체를 배열로 표시
z.treeifyError(error) 오류 객체를 트리 구조로 표시
z.prettifyError(error) 사람이 읽을 수 있는 문자로 표시
z.flattenError(error) 깔끔하고 얕은 오류 표시
TS
content_copy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
import { z } from 'zod' import { ko } from 'zod/locales' z.config(ko()) try { z.strictObject({ name: z.string(), age: z.number() }).parse({ name: 123, age: 'Neo', isValid: true }) } catch (error) { if (error instanceof z.ZodError) { console.log(error.issues) // 오류 객체를 배열로 표시 // => [ // { // expected: 'string', // code: 'invalid_type', // path: [ 'name' ], // message: '잘못된 입력: 예상 타입은 string, 받은 타입은 number입니다' // }, // { // expected: 'number', // code: 'invalid_type', // path: [ 'age' ], // message: '잘못된 입력: 예상 타입은 number, 받은 타입은 string입니다' // }, // { // code: 'unrecognized_keys', // keys: [ 'isValid' ], // path: [], // message: '인식할 수 없는 키: "isValid"' // } // ] console.log(z.treeifyError(error)) // 오류 객체를 트리 구조로 표시 // => { // errors: [ '인식할 수 없는 키: "isValid"' ], // properties: { // name: { // errors: [ '잘못된 입력: 예상 타입은 string, 받은 타입은 number입니다' ] // }, // age: { // errors: [ '잘못된 입력: 예상 타입은 number, 받은 타입은 string입니다' ] // } // } // } console.log(z.prettifyError(error)) // 사람이 읽을 수 있는 문자로 표시 // => ✖ 인식할 수 없는 키: 'isValid' // ✖ 잘못된 입력: 예상 타입은 string, 받은 타입은 number입니다 // → at name // ✖ 잘못된 입력: 예상 타입은 number, 받은 타입은 string입니다 // → at age console.log(z.flattenError(error)) // 깔끔하고 얕은 오류 표시 // => { // formErrors: [ '인식할 수 없는 키: "isValid"' ], // fieldErrors: { // name: [ '잘못된 입력: 예상 타입은 string, 받은 타입은 number입니다' ], // age: [ '잘못된 입력: 예상 타입은 number, 받은 타입은 string입니다' ] // } // } } }

# 예제

# 로그인 양식 검증

info

다음은 React 프로젝트 시작하기 w. Vite를 기반으로 하는 예제입니다.

로그인 양식 검증 예제 출력 zoom_out_map

/src/App.tsx
TSX
content_copy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
import { useState } from 'react' import { z } from 'zod' const schema = z.object({ id: z.email({ error: '이메일 형식이 올바르지 않습니다.' }), pw: z.string().min(5, { error: issue => `비밀번호는 ${issue.minimum}자 이상이어야 합니다.` }) }) function TextField( props: React.InputHTMLAttributes<HTMLInputElement> & { error: string } ) { return ( <div className="mb-3"> <input className="w-full rounded-md border-2 border-gray-300 p-2" {...props} /> {props.error && ( <div className="mt-1 text-sm text-red-500">{props.error}</div> )} </div> ) } export default function App() { const [id, setId] = useState('') const [pw, setPw] = useState('') const [idError, setIdError] = useState('') const [pwError, setPwError] = useState('') function validateFields() { setIdError('') setPwError('') const result = schema.safeParse({ id, pw }) if (result.error instanceof z.ZodError) { result.error.issues.forEach(issue => { switch (issue.path[0]) { case 'id': setIdError(issue.message) break case 'pw': setPwError(issue.message) break } }) return false } return true } function handleSubmit(e: React.FormEvent<HTMLFormElement>) { e.preventDefault() if (!validateFields()) return // 로그인 처리.. console.log('로그인!') } return ( <> <form className="max-w-md px-4 py-6" onSubmit={handleSubmit}> <TextField placeholder="이메일을 입력하세요." type="text" value={id} onChange={e => setId(e.target.value)} error={idError} /> <TextField placeholder="비밀번호를 입력하세요." type="password" value={pw} onChange={e => setPw(e.target.value)} error={pwError} /> <button className="w-full cursor-pointer rounded-md bg-blue-500 p-2 text-white hover:bg-blue-600" type="submit"> 로그인 </button> </form> </> ) }