Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
18 changes: 13 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

61 changes: 31 additions & 30 deletions src/json-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]) => {
Expand All @@ -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)}`
Expand Down Expand Up @@ -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))
Expand All @@ -136,46 +153,30 @@ 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',
choices: propertyValue.anyOf.map(subSchema => (subSchema as {const: string}).const),
} 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',
Expand Down
2 changes: 1 addition & 1 deletion test/zod3.test.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down
2 changes: 1 addition & 1 deletion vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading