UNPKG

zod

Version:

TypeScript-first schema declaration and validation library with static type inference

631 lines (562 loc) • 21.3 kB
import type * as checks from "./checks.js"; import type * as JSONSchema from "./json-schema.js"; import type { $ZodRegistry } from "./registries.js"; import type * as schemas from "./schemas.js"; import { type Processor, type RegistryToJSONSchemaParams, type ToJSONSchemaParams, type ZodStandardJSONSchemaPayload, extractDefs, finalize, initializeContext, process, } from "./to-json-schema.js"; import { getEnumValues } from "./util.js"; const formatMap: Partial<Record<checks.$ZodStringFormats, string | undefined>> = { guid: "uuid", url: "uri", datetime: "date-time", json_string: "json-string", regex: "", // do not set }; // ==================== SIMPLE TYPE PROCESSORS ==================== export const stringProcessor: Processor<schemas.$ZodString> = (schema, ctx, _json, _params) => { const json = _json as JSONSchema.StringSchema; json.type = "string"; const { minimum, maximum, format, patterns, contentEncoding } = schema._zod .bag as schemas.$ZodStringInternals<unknown>["bag"]; if (typeof minimum === "number") json.minLength = minimum; if (typeof maximum === "number") json.maxLength = maximum; // custom pattern overrides format if (format) { json.format = formatMap[format as checks.$ZodStringFormats] ?? format; if (json.format === "") delete json.format; // empty format is not valid } if (contentEncoding) json.contentEncoding = contentEncoding; if (patterns && patterns.size > 0) { const regexes = [...patterns]; if (regexes.length === 1) json.pattern = regexes[0]!.source; else if (regexes.length > 1) { json.allOf = [ ...regexes.map((regex) => ({ ...(ctx.target === "draft-07" || ctx.target === "draft-04" || ctx.target === "openapi-3.0" ? ({ type: "string" } as const) : {}), pattern: regex.source, })), ]; } } }; export const numberProcessor: Processor<schemas.$ZodNumber> = (schema, ctx, _json, _params) => { const json = _json as JSONSchema.NumberSchema | JSONSchema.IntegerSchema; const { minimum, maximum, format, multipleOf, exclusiveMaximum, exclusiveMinimum } = schema._zod.bag; if (typeof format === "string" && format.includes("int")) json.type = "integer"; else json.type = "number"; if (typeof exclusiveMinimum === "number") { if (ctx.target === "draft-04" || ctx.target === "openapi-3.0") { json.minimum = exclusiveMinimum; json.exclusiveMinimum = true; } else { json.exclusiveMinimum = exclusiveMinimum; } } if (typeof minimum === "number") { json.minimum = minimum; if (typeof exclusiveMinimum === "number" && ctx.target !== "draft-04") { if (exclusiveMinimum >= minimum) delete json.minimum; else delete json.exclusiveMinimum; } } if (typeof exclusiveMaximum === "number") { if (ctx.target === "draft-04" || ctx.target === "openapi-3.0") { json.maximum = exclusiveMaximum; json.exclusiveMaximum = true; } else { json.exclusiveMaximum = exclusiveMaximum; } } if (typeof maximum === "number") { json.maximum = maximum; if (typeof exclusiveMaximum === "number" && ctx.target !== "draft-04") { if (exclusiveMaximum <= maximum) delete json.maximum; else delete json.exclusiveMaximum; } } if (typeof multipleOf === "number") json.multipleOf = multipleOf; }; export const booleanProcessor: Processor<schemas.$ZodBoolean> = (_schema, _ctx, json, _params) => { (json as JSONSchema.BooleanSchema).type = "boolean"; }; export const bigintProcessor: Processor<schemas.$ZodBigInt> = (_schema, ctx, _json, _params) => { if (ctx.unrepresentable === "throw") { throw new Error("BigInt cannot be represented in JSON Schema"); } }; export const symbolProcessor: Processor<schemas.$ZodSymbol> = (_schema, ctx, _json, _params) => { if (ctx.unrepresentable === "throw") { throw new Error("Symbols cannot be represented in JSON Schema"); } }; export const nullProcessor: Processor<schemas.$ZodNull> = (_schema, ctx, json, _params) => { if (ctx.target === "openapi-3.0") { json.type = "string"; json.nullable = true; json.enum = [null]; } else { json.type = "null"; } }; export const undefinedProcessor: Processor<schemas.$ZodUndefined> = (_schema, ctx, _json, _params) => { if (ctx.unrepresentable === "throw") { throw new Error("Undefined cannot be represented in JSON Schema"); } }; export const voidProcessor: Processor<schemas.$ZodVoid> = (_schema, ctx, _json, _params) => { if (ctx.unrepresentable === "throw") { throw new Error("Void cannot be represented in JSON Schema"); } }; export const neverProcessor: Processor<schemas.$ZodNever> = (_schema, _ctx, json, _params) => { json.not = {}; }; export const anyProcessor: Processor<schemas.$ZodAny> = (_schema, _ctx, _json, _params) => { // empty schema accepts anything }; export const unknownProcessor: Processor<schemas.$ZodUnknown> = (_schema, _ctx, _json, _params) => { // empty schema accepts anything }; export const dateProcessor: Processor<schemas.$ZodDate> = (_schema, ctx, _json, _params) => { if (ctx.unrepresentable === "throw") { throw new Error("Date cannot be represented in JSON Schema"); } }; export const enumProcessor: Processor<schemas.$ZodEnum> = (schema, _ctx, json, _params) => { const def = schema._zod.def as schemas.$ZodEnumDef; const values = getEnumValues(def.entries); // Number enums can have both string and number values if (values.every((v) => typeof v === "number")) json.type = "number"; if (values.every((v) => typeof v === "string")) json.type = "string"; json.enum = values; }; export const literalProcessor: Processor<schemas.$ZodLiteral> = (schema, ctx, json, _params) => { const def = schema._zod.def as schemas.$ZodLiteralDef<any>; const vals: (string | number | boolean | null)[] = []; for (const val of def.values) { if (val === undefined) { if (ctx.unrepresentable === "throw") { throw new Error("Literal `undefined` cannot be represented in JSON Schema"); } else { // do not add to vals } } else if (typeof val === "bigint") { if (ctx.unrepresentable === "throw") { throw new Error("BigInt literals cannot be represented in JSON Schema"); } else { vals.push(Number(val)); } } else { vals.push(val); } } if (vals.length === 0) { // do nothing (an undefined literal was stripped) } else if (vals.length === 1) { const val = vals[0]!; json.type = val === null ? ("null" as const) : (typeof val as any); if (ctx.target === "draft-04" || ctx.target === "openapi-3.0") { json.enum = [val]; } else { json.const = val; } } else { if (vals.every((v) => typeof v === "number")) json.type = "number"; if (vals.every((v) => typeof v === "string")) json.type = "string"; if (vals.every((v) => typeof v === "boolean")) json.type = "boolean"; if (vals.every((v) => v === null)) json.type = "null"; json.enum = vals; } }; export const nanProcessor: Processor<schemas.$ZodNaN> = (_schema, ctx, _json, _params) => { if (ctx.unrepresentable === "throw") { throw new Error("NaN cannot be represented in JSON Schema"); } }; export const templateLiteralProcessor: Processor<schemas.$ZodTemplateLiteral> = (schema, _ctx, json, _params) => { const _json = json as JSONSchema.StringSchema; const pattern = schema._zod.pattern; if (!pattern) throw new Error("Pattern not found in template literal"); _json.type = "string"; _json.pattern = pattern.source; }; export const fileProcessor: Processor<schemas.$ZodFile> = (schema, _ctx, json, _params) => { const _json = json as JSONSchema.StringSchema; const file: JSONSchema.StringSchema = { type: "string", format: "binary", contentEncoding: "binary", }; const { minimum, maximum, mime } = schema._zod.bag as schemas.$ZodFileInternals["bag"]; if (minimum !== undefined) file.minLength = minimum; if (maximum !== undefined) file.maxLength = maximum; if (mime) { if (mime.length === 1) { file.contentMediaType = mime[0]!; Object.assign(_json, file); } else { _json.anyOf = mime.map((m) => { const mFile: JSONSchema.StringSchema = { ...file, contentMediaType: m }; return mFile; }); } } else { Object.assign(_json, file); } }; export const successProcessor: Processor<schemas.$ZodSuccess> = (_schema, _ctx, json, _params) => { (json as JSONSchema.BooleanSchema).type = "boolean"; }; export const customProcessor: Processor<schemas.$ZodCustom> = (_schema, ctx, _json, _params) => { if (ctx.unrepresentable === "throw") { throw new Error("Custom types cannot be represented in JSON Schema"); } }; export const functionProcessor: Processor<schemas.$ZodFunction> = (_schema, ctx, _json, _params) => { if (ctx.unrepresentable === "throw") { throw new Error("Function types cannot be represented in JSON Schema"); } }; export const transformProcessor: Processor<schemas.$ZodTransform> = (_schema, ctx, _json, _params) => { if (ctx.unrepresentable === "throw") { throw new Error("Transforms cannot be represented in JSON Schema"); } }; export const mapProcessor: Processor<schemas.$ZodMap> = (_schema, ctx, _json, _params) => { if (ctx.unrepresentable === "throw") { throw new Error("Map cannot be represented in JSON Schema"); } }; export const setProcessor: Processor<schemas.$ZodSet> = (_schema, ctx, _json, _params) => { if (ctx.unrepresentable === "throw") { throw new Error("Set cannot be represented in JSON Schema"); } }; // ==================== COMPOSITE TYPE PROCESSORS ==================== export const arrayProcessor: Processor<schemas.$ZodArray> = (schema, ctx, _json, params) => { const json = _json as JSONSchema.ArraySchema; const def = schema._zod.def as schemas.$ZodArrayDef; const { minimum, maximum } = schema._zod.bag; if (typeof minimum === "number") json.minItems = minimum; if (typeof maximum === "number") json.maxItems = maximum; json.type = "array"; json.items = process(def.element, ctx as any, { ...params, path: [...params.path, "items"] }); }; export const objectProcessor: Processor<schemas.$ZodObject> = (schema, ctx, _json, params) => { const json = _json as JSONSchema.ObjectSchema; const def = schema._zod.def as schemas.$ZodObjectDef; json.type = "object"; json.properties = {}; const shape = def.shape; for (const key in shape) { json.properties[key] = process(shape[key]!, ctx as any, { ...params, path: [...params.path, "properties", key], }); } // required keys const allKeys = new Set(Object.keys(shape)); const requiredKeys = new Set( [...allKeys].filter((key) => { const v = def.shape[key]!._zod; if (ctx.io === "input") { return v.optin === undefined; } else { return v.optout === undefined; } }) ); if (requiredKeys.size > 0) { json.required = Array.from(requiredKeys); } // catchall if (def.catchall?._zod.def.type === "never") { // strict json.additionalProperties = false; } else if (!def.catchall) { // regular if (ctx.io === "output") json.additionalProperties = false; } else if (def.catchall) { json.additionalProperties = process(def.catchall, ctx as any, { ...params, path: [...params.path, "additionalProperties"], }); } }; export const unionProcessor: Processor<schemas.$ZodUnion> = (schema, ctx, json, params) => { const def = schema._zod.def as schemas.$ZodUnionDef; // Exclusive unions (inclusive === false) use oneOf (exactly one match) instead of anyOf (one or more matches) // This includes both z.xor() and discriminated unions const isExclusive = def.inclusive === false; const options = def.options.map((x, i) => process(x, ctx as any, { ...params, path: [...params.path, isExclusive ? "oneOf" : "anyOf", i], }) ); if (isExclusive) { json.oneOf = options; } else { json.anyOf = options; } }; export const intersectionProcessor: Processor<schemas.$ZodIntersection> = (schema, ctx, json, params) => { const def = schema._zod.def as schemas.$ZodIntersectionDef; const a = process(def.left, ctx as any, { ...params, path: [...params.path, "allOf", 0], }); const b = process(def.right, ctx as any, { ...params, path: [...params.path, "allOf", 1], }); const isSimpleIntersection = (val: any) => "allOf" in val && Object.keys(val).length === 1; const allOf = [ ...(isSimpleIntersection(a) ? (a.allOf as any[]) : [a]), ...(isSimpleIntersection(b) ? (b.allOf as any[]) : [b]), ]; json.allOf = allOf; }; export const tupleProcessor: Processor<schemas.$ZodTuple> = (schema, ctx, _json, params) => { const json = _json as JSONSchema.ArraySchema; const def = schema._zod.def as schemas.$ZodTupleDef; json.type = "array"; const prefixPath = ctx.target === "draft-2020-12" ? "prefixItems" : "items"; const restPath = ctx.target === "draft-2020-12" ? "items" : ctx.target === "openapi-3.0" ? "items" : "additionalItems"; const prefixItems = def.items.map((x, i) => process(x, ctx as any, { ...params, path: [...params.path, prefixPath, i], }) ); const rest = def.rest ? process(def.rest, ctx as any, { ...params, path: [...params.path, restPath, ...(ctx.target === "openapi-3.0" ? [def.items.length] : [])], }) : null; if (ctx.target === "draft-2020-12") { json.prefixItems = prefixItems; if (rest) { json.items = rest; } } else if (ctx.target === "openapi-3.0") { json.items = { anyOf: prefixItems, }; if (rest) { json.items.anyOf!.push(rest); } json.minItems = prefixItems.length; if (!rest) { json.maxItems = prefixItems.length; } } else { json.items = prefixItems; if (rest) { json.additionalItems = rest; } } // length const { minimum, maximum } = schema._zod.bag as { minimum?: number; maximum?: number; }; if (typeof minimum === "number") json.minItems = minimum; if (typeof maximum === "number") json.maxItems = maximum; }; export const recordProcessor: Processor<schemas.$ZodRecord> = (schema, ctx, _json, params) => { const json = _json as JSONSchema.ObjectSchema; const def = schema._zod.def as schemas.$ZodRecordDef; json.type = "object"; if (ctx.target === "draft-07" || ctx.target === "draft-2020-12") { json.propertyNames = process(def.keyType, ctx as any, { ...params, path: [...params.path, "propertyNames"], }); } json.additionalProperties = process(def.valueType, ctx as any, { ...params, path: [...params.path, "additionalProperties"], }); }; export const nullableProcessor: Processor<schemas.$ZodNullable> = (schema, ctx, json, params) => { const def = schema._zod.def as schemas.$ZodNullableDef; const inner = process(def.innerType, ctx as any, params); const seen = ctx.seen.get(schema)!; if (ctx.target === "openapi-3.0") { seen.ref = def.innerType; json.nullable = true; } else { json.anyOf = [inner, { type: "null" }]; } }; export const nonoptionalProcessor: Processor<schemas.$ZodNonOptional> = (schema, ctx, _json, params) => { const def = schema._zod.def as schemas.$ZodNonOptionalDef; process(def.innerType, ctx as any, params); const seen = ctx.seen.get(schema)!; seen.ref = def.innerType; }; export const defaultProcessor: Processor<schemas.$ZodDefault> = (schema, ctx, json, params) => { const def = schema._zod.def as schemas.$ZodDefaultDef; process(def.innerType, ctx as any, params); const seen = ctx.seen.get(schema)!; seen.ref = def.innerType; json.default = JSON.parse(JSON.stringify(def.defaultValue)); }; export const prefaultProcessor: Processor<schemas.$ZodPrefault> = (schema, ctx, json, params) => { const def = schema._zod.def as schemas.$ZodPrefaultDef; process(def.innerType, ctx as any, params); const seen = ctx.seen.get(schema)!; seen.ref = def.innerType; if (ctx.io === "input") json._prefault = JSON.parse(JSON.stringify(def.defaultValue)); }; export const catchProcessor: Processor<schemas.$ZodCatch> = (schema, ctx, json, params) => { const def = schema._zod.def as schemas.$ZodCatchDef; process(def.innerType, ctx as any, params); const seen = ctx.seen.get(schema)!; seen.ref = def.innerType; let catchValue: any; try { catchValue = def.catchValue(undefined as any); } catch { throw new Error("Dynamic catch values are not supported in JSON Schema"); } json.default = catchValue; }; export const pipeProcessor: Processor<schemas.$ZodPipe> = (schema, ctx, _json, params) => { const def = schema._zod.def as schemas.$ZodPipeDef; const innerType = ctx.io === "input" ? (def.in._zod.def.type === "transform" ? def.out : def.in) : def.out; process(innerType, ctx as any, params); const seen = ctx.seen.get(schema)!; seen.ref = innerType; }; export const readonlyProcessor: Processor<schemas.$ZodReadonly> = (schema, ctx, json, params) => { const def = schema._zod.def as schemas.$ZodReadonlyDef; process(def.innerType, ctx as any, params); const seen = ctx.seen.get(schema)!; seen.ref = def.innerType; json.readOnly = true; }; export const promiseProcessor: Processor<schemas.$ZodPromise> = (schema, ctx, _json, params) => { const def = schema._zod.def as schemas.$ZodPromiseDef; process(def.innerType, ctx as any, params); const seen = ctx.seen.get(schema)!; seen.ref = def.innerType; }; export const optionalProcessor: Processor<schemas.$ZodOptional> = (schema, ctx, _json, params) => { const def = schema._zod.def as schemas.$ZodOptionalDef; process(def.innerType, ctx as any, params); const seen = ctx.seen.get(schema)!; seen.ref = def.innerType; }; export const lazyProcessor: Processor<schemas.$ZodLazy> = (schema, ctx, _json, params) => { const innerType = (schema as schemas.$ZodLazy)._zod.innerType; process(innerType, ctx as any, params); const seen = ctx.seen.get(schema)!; seen.ref = innerType; }; // ==================== ALL PROCESSORS ==================== export const allProcessors: Record<string, Processor<any>> = { string: stringProcessor, number: numberProcessor, boolean: booleanProcessor, bigint: bigintProcessor, symbol: symbolProcessor, null: nullProcessor, undefined: undefinedProcessor, void: voidProcessor, never: neverProcessor, any: anyProcessor, unknown: unknownProcessor, date: dateProcessor, enum: enumProcessor, literal: literalProcessor, nan: nanProcessor, template_literal: templateLiteralProcessor, file: fileProcessor, success: successProcessor, custom: customProcessor, function: functionProcessor, transform: transformProcessor, map: mapProcessor, set: setProcessor, array: arrayProcessor, object: objectProcessor, union: unionProcessor, intersection: intersectionProcessor, tuple: tupleProcessor, record: recordProcessor, nullable: nullableProcessor, nonoptional: nonoptionalProcessor, default: defaultProcessor, prefault: prefaultProcessor, catch: catchProcessor, pipe: pipeProcessor, readonly: readonlyProcessor, promise: promiseProcessor, optional: optionalProcessor, lazy: lazyProcessor, }; // ==================== TOP-LEVEL toJSONSchema ==================== export function toJSONSchema<T extends schemas.$ZodType>( schema: T, params?: ToJSONSchemaParams ): ZodStandardJSONSchemaPayload<T>; export function toJSONSchema( registry: $ZodRegistry<{ id?: string | undefined }>, params?: RegistryToJSONSchemaParams ): { schemas: Record<string, ZodStandardJSONSchemaPayload<schemas.$ZodType>> }; export function toJSONSchema( input: schemas.$ZodType | $ZodRegistry<{ id?: string | undefined }>, params?: ToJSONSchemaParams | RegistryToJSONSchemaParams ): any { if ("_idmap" in input) { // Registry case const registry = input as $ZodRegistry<{ id?: string | undefined }>; const ctx = initializeContext({ ...params, processors: allProcessors }); const defs: any = {}; // First pass: process all schemas to build the seen map for (const entry of registry._idmap.entries()) { const [_, schema] = entry; process(schema, ctx as any); } const schemas: Record<string, JSONSchema.BaseSchema> = {}; const external = { registry, uri: (params as RegistryToJSONSchemaParams)?.uri, defs, }; // Update the context with external configuration ctx.external = external; // Second pass: emit each schema for (const entry of registry._idmap.entries()) { const [key, schema] = entry; extractDefs(ctx as any, schema); schemas[key] = finalize(ctx as any, schema); } if (Object.keys(defs).length > 0) { const defsSegment = ctx.target === "draft-2020-12" ? "$defs" : "definitions"; schemas.__shared = { [defsSegment]: defs, }; } return { schemas }; } // Single schema case const ctx = initializeContext({ ...params, processors: allProcessors }); process(input, ctx as any); extractDefs(ctx as any, input); return finalize(ctx as any, input); }