한 줄 정의
z.toJSONSchema는 Zod 스키마를 표준 JSON Schema 문서로 바꾸는 함수다. 스키마 하나를 넘기면 그 모양을 그대로 옮긴 JSON Schema 객체가 나오고, OpenAPI 정의나 AI 구조적 출력 스펙처럼 JSON Schema를 요구하는 자리에 바로 넘긴다.
기본 대상은 JSON Schema Draft 2020-12다. z.object는 type: 'object'와 properties, 그리고 필수 키를 모은 required로 옮겨진다.
import { z } from 'zod'
const User = z.object({
name: z.string(),
age: z.number(),
})
z.toJSONSchema(User)
// {
// type: 'object',
// properties: { name: { type: 'string' }, age: { type: 'number' } },
// required: ['name', 'age'],
// additionalProperties: false,
// }
검증에 쓰던 스키마 그대로가 명세가 된다. 타입을 검사하는 곳과 스펙을 배포하는 곳이 같은 선언에서 나오니 둘이 어긋날 일이 없다.
작동 원리
z.toJSONSchema는 스키마 트리를 훑어 노드마다 대응하는 JSON Schema 키워드로 옮긴다. z.string()은 { type: 'string' }, z.object()는 type: 'object'에 properties·required·additionalProperties: false가 붙는다. required에는 optional이 아닌 키가 전부 들어간다.
.meta()로 붙인 메타데이터는 레지스트리에서 읽혀 출력에 그대로 실린다. title·description처럼 JSON Schema가 아는 키는 같은 이름으로 나간다.
const email = z.string().meta({
title: 'Email address',
description: '가입에 쓰는 이메일',
})
z.toJSONSchema(email)
// { type: 'string', title: 'Email address', description: '가입에 쓰는 이메일' }
Zod 타입 중에는 JSON Schema로 옮길 짝이 없는 것도 있다. bigint·date·symbol·map·set, 그리고 transform 같은 변환이 그렇다. 기본 동작은 그런 노드를 만나면 조용히 넘기지 않고 에러를 던진다.
z.toJSONSchema(z.bigint())
// Error를 throw — bigint는 표현할 짝이 없다
순환 참조는 $ref로 끊는다. 스키마가 자기 자신을 다시 가리키면, 무한히 펼치지 않고 루트를 가리키는 참조를 남긴다.
const Node = z.object({
name: z.string(),
get friend() {
return Node
},
})
z.toJSONSchema(Node)
// properties.friend 자리에 { '$ref': '#' }가 들어간다
한 가지 더, z.toJSONSchema는 기본으로 출력 타입을 그린다. transform이나 .default(), z.coerce가 낀 스키마는 입력과 출력의 타입이 갈리는데, 별도 지정이 없으면 파싱을 마친 뒤의 모양을 그린다. 이 갈림이 실무에서 가장 자주 발을 거는 지점이다.
쓰는 법
첫 인자는 스키마, 둘째 인자는 옵션이다. 옵션 없이 부르면 Draft 2020-12를 대상으로 출력 타입을 그린다.
z.toJSONSchema(schema, params?)
대상 규격은 target으로 고른다. 'draft-2020-12'가 기본이고, 구형 도구에는 'draft-07'이나 'draft-04', OpenAPI 문서에는 'openapi-3.0'을 준다.
입력과 출력 중 어느 쪽을 그릴지는 io로 정한다. 'output'이 기본이고, 모델이나 클라이언트가 보낼 값의 모양이 필요하면 'input'을 준다.
옮길 짝이 없는 타입의 처리는 unrepresentable로 바꾼다. 'throw'가 기본이라 date나 bigint에서 막히는데, 명세를 끊기지 않게 뽑아야 하면 'any'로 두어 빈 스키마 {}로 흘려보낸다.
// OpenAPI 3.0 문서에 넣을 형태
z.toJSONSchema(User, { target: 'openapi-3.0' })
// 표현 불가 타입을 막지 않고 {}로 흘려보낸다
z.toJSONSchema(z.date(), { unrepresentable: 'any' }) // {}
// 나온 JSON Schema를 직접 손본다
z.toJSONSchema(User, {
override: (ctx) => {
ctx.zodSchema // 원래 Zod 스키마
ctx.jsonSchema // 지금까지 만들어진 JSON Schema
ctx.jsonSchema.title = 'User'
},
})
여러 스키마를 한 문서로 묶으려면 레지스트리를 통째로 넘긴다. 이때는 스키마마다 id가 등록돼 있어야 서로를 $ref로 가리킨다.
z.globalRegistry.add(User, { id: 'User' })
z.globalRegistry.add(Post, { id: 'Post' })
z.toJSONSchema(z.globalRegistry)
// { schemas: { User: { id: 'User', ... }, Post: { ... } } }
실전 예제
가장 잦은 쓸모는 LLM 구조적 출력이다. 모델이 자유 텍스트가 아니라 정해진 모양의 JSON을 뱉게 하려면 스키마를 JSON Schema로 넘겨야 하는데, 검증에 쓰는 Zod 스키마에서 그 명세를 바로 뽑는다.
import { z } from 'zod'
const Recipe = z.object({
title: z.string(),
servings: z.number().int().positive(),
tags: z.array(z.string()),
})
// 모델에 넘길 응답 스펙 — 모델이 만들어 낼 값의 모양이 필요하다
const responseSchema = z.toJSONSchema(Recipe, { io: 'input' })
const res = await callModel({ responseSchema })
// 돌아온 JSON은 여전히 unknown이라 같은 스키마로 한 번 검증한다
const recipe = Recipe.parse(res.output)
명세를 뽑는 스키마와 응답을 검증하는 스키마가 하나라, 모델에 알린 계약과 실제로 받아들이는 계약이 갈라지지 않는다.
쓰는 이유
JSON Schema는 타입 검증기 사이의 공용어다. OpenAPI 문서, LLM 구조적 출력, 폼 생성기, 여러 언어의 검증 라이브러리가 전부 이 규격을 읽는다. Zod로 검증하는 코드베이스에서 이 명세를 손으로 또 적으면, 스키마를 고칠 때마다 명세도 같이 고쳐야 하고 어느 순간 둘이 어긋난다. z.toJSONSchema는 검증에 쓰던 스키마 그대로에서 명세를 뽑아 그 어긋남을 지운다. 다만 기본이 출력 타입을 그린다는 점과 표현 불가 타입에서 막힌다는 점, 이 두 가지만은 옵션으로 짚어 두고 써야 한다.
Reference