diff --git a/package.json b/package.json index 4c1beb68..72748447 100644 --- a/package.json +++ b/package.json @@ -100,7 +100,8 @@ "typescript": "5.9.3", "valibot": "1.2.0", "vitest": "4.0.18", - "zod": "3.25.76", + "zod3": "npm:zod@3.25.76", + "zod": "4.3.6", "zod-to-json-schema": "3.25.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c4799565..64e5a131 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -100,11 +100,14 @@ importers: specifier: 4.0.18 version: 4.0.18(@types/node@24.10.13)(tsx@4.21.0) zod: - specifier: 3.25.76 - version: 3.25.76 + specifier: 4.3.6 + version: 4.3.6 zod-to-json-schema: specifier: 3.25.1 - version: 3.25.1(zod@3.25.76) + version: 3.25.1(zod@4.3.6) + zod3: + specifier: npm:zod@3.25.76 + version: zod@3.25.76 packages: @@ -4232,6 +4235,9 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zx@8.8.5: resolution: {integrity: sha512-SNgDF5L0gfN7FwVOdEFguY3orU5AkfFZm9B5YSHog/UDHv+lvmd82ZAsOenOkQixigwH2+yyH198AwNdKhj+RA==} engines: {node: '>= 12.17.0'} @@ -8703,10 +8709,12 @@ snapshots: dependencies: zod: 3.25.76 - zod-to-json-schema@3.25.1(zod@3.25.76): + zod-to-json-schema@3.25.1(zod@4.3.6): dependencies: - zod: 3.25.76 + zod: 4.3.6 zod@3.25.76: {} + zod@4.3.6: {} + zx@8.8.5: {} diff --git a/src/json-schema.ts b/src/json-schema.ts index 1ec30def..26c99f7f 100644 --- a/src/json-schema.ts +++ b/src/json-schema.ts @@ -81,6 +81,7 @@ export const getDescription = (v: JSONSchema7, depth = 0): string => { if (k.startsWith('$')) return false // helpers props to add on to a few different external library output formats if (k === 'maximum' && vv === Number.MAX_SAFE_INTEGER) return false // zod adds this for `z.number().int().positive()` if (depth <= 1 && k === 'enum' && getEnumChoices(v)?.type === 'string_enum') return false // don't show Enum: ["a","b"], that's handled by commander's `choices` + if (depth === 0 && k === 'anyOf' && getEnumChoices(v)?.type === 'string_enum') return false return true }) .sort(([a], [b]) => { @@ -89,6 +90,21 @@ export const getDescription = (v: JSONSchema7, depth = 0): string => { }) .map(([k, vv], i) => { if (k === 'type' && Array.isArray(vv)) return `type: ${vv.join(' or ')}` + if (k === 'anyOf') { + const anyOf = (v.anyOf || []) as JSONSchema7[] + const isSimpleAnyOf = anyOf.every(subSchema => { + if (!subSchema || typeof subSchema !== 'object') return false + return Object.keys(subSchema).every(key => key === 'type' || key === 'const') + }) + + if (isSimpleAnyOf) { + const types = [...new Set(anyOf.flatMap(getSchemaTypes))] + if (types.length > 0) { + const label = depth > 0 ? 'Type' : 'type' + return `${label}: ${types.join(' or ')}` + } + } + } if (k === 'description' && i === 0) return String(vv) if (k === 'properties') return `Object (json formatted)` if (typeof vv === 'object') return `${capitaliseFromCamelCase(k)}: ${JSON.stringify(vv)}` @@ -126,6 +142,7 @@ export const getSchemaTypes = ( /** Returns a list of all allowed subschemas. If the schema is not a union, returns a list with a single item. */ export const getAllowedSchemas = (schema: JSONSchema7): JSONSchema7[] => { if (!schema) return [] + if (getEnumChoices(schema)) return [schema] if ('anyOf' in schema && Array.isArray(schema.anyOf)) return (schema.anyOf as JSONSchema7[]).flatMap(getAllowedSchemas) if ('oneOf' in schema && Array.isArray(schema.oneOf)) @@ -136,24 +153,22 @@ export const getAllowedSchemas = (schema: JSONSchema7): JSONSchema7[] => { } export const getEnumChoices = (propertyValue: JSONSchema7) => { + const isLiteralAnyOfEntry = ( + subSchema: unknown, + expectedType: 'string' | 'number', + ): subSchema is {const: string} | {const: number} => { + if (!subSchema || typeof subSchema !== 'object' || !('const' in subSchema)) return false + if (typeof subSchema.const !== expectedType) return false + if ('type' in subSchema && subSchema.type != null && ![subSchema.type].flat().includes(expectedType)) { + return false + } + return Object.keys(subSchema).every(key => key === 'const' || key === 'type') + } + if (!propertyValue) return null if (!('enum' in propertyValue && Array.isArray(propertyValue.enum))) { // arktype prefers {anyOf: [{const: 'foo'}, {const: 'bar'}]} over {enum: ['foo', 'bar']} 🤷 - if ( - 'anyOf' in propertyValue && - propertyValue.anyOf?.every(subSchema => { - if ( - subSchema && - typeof subSchema === 'object' && - 'const' in subSchema && - Object.keys(subSchema).length === 1 && - typeof subSchema.const === 'string' - ) { - return true - } - return false - }) - ) { + if ('anyOf' in propertyValue && propertyValue.anyOf?.every(subSchema => isLiteralAnyOfEntry(subSchema, 'string'))) { // all the subschemas are string literals, so we can use them as choices return { type: 'string_enum', @@ -161,21 +176,7 @@ export const getEnumChoices = (propertyValue: JSONSchema7) => { } as const } - if ( - 'anyOf' in propertyValue && - propertyValue.anyOf?.every(subSchema => { - if ( - subSchema && - typeof subSchema === 'object' && - 'const' in subSchema && - Object.keys(subSchema).length === 1 && - typeof subSchema.const === 'number' - ) { - return true - } - return false - }) - ) { + if ('anyOf' in propertyValue && propertyValue.anyOf?.every(subSchema => isLiteralAnyOfEntry(subSchema, 'number'))) { // all the subschemas are string literals, so we can use them as choices return { type: 'number_enum', diff --git a/test/zod3.test.ts b/test/zod3.test.ts index 1c960d23..6d3e586c 100644 --- a/test/zod3.test.ts +++ b/test/zod3.test.ts @@ -1,7 +1,7 @@ import {initTRPC} from '@trpc/server' import {inspect} from 'util' import {expect, test} from 'vitest' -import {z} from 'zod/v3' // same as 'zod' but this is more explicit +import {z} from 'zod3' import {createCli, TrpcCliMeta} from '../src/index.js' import {run, snapshotSerializer} from './test-run.js' diff --git a/vite.config.ts b/vite.config.ts index 3530063a..1ddaa0ca 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,7 +2,7 @@ import {defineConfig} from 'vitest/config' export default defineConfig({ test: { - exclude: ['*ignoreme*', 'node_modules'], + exclude: ['*ignoreme*', 'node_modules', '.opencode'], setupFiles: ['./test/setup.ts'], typecheck: { enabled: true,