TypeScript circular reference issue when generating Zod schema from JSON Schema metaschema #4747
-
|
Hi, As an experiment, I tried generating Zod schemas from the JSON Schema metaschema (Draft 7), and this is the result: Generated Zod schemaimport { z } from 'zod/v4';
export const NonNegativeInteger = z.int().min(0);
export type NonNegativeInteger = z.infer<typeof NonNegativeInteger>;
export const NonNegativeIntegerDefault0 = z.intersection(
NonNegativeInteger,
z.number().default(0)
);
export type NonNegativeIntegerDefault0 = z.infer<
typeof NonNegativeIntegerDefault0
>;
export const SchemaArray = z
.array(z.lazy<typeof CoreSchemaMetaSchema>(() => CoreSchemaMetaSchema))
.min(1);
export type SchemaArray = z.infer<typeof SchemaArray>;
export const StringArray = z.array(z.string()).default([]);
export type StringArray = z.infer<typeof StringArray>;
export const SimpleTypes = z.enum([
'array',
'boolean',
'integer',
'null',
'number',
'object',
'string',
]);
export type SimpleTypes = z.infer<typeof SimpleTypes>;
export const CoreSchemaMetaSchema = z
.union([
z.object({
$id: z.string().optional(),
$schema: z.string().url().optional(),
$ref: z.string().optional(),
$comment: z.string().optional(),
title: z.string().optional(),
description: z.string().optional(),
default: z.unknown().optional(),
readOnly: z.boolean().default(false),
writeOnly: z.boolean().default(false),
examples: z.array(z.unknown()).optional(),
multipleOf: z.number().gt(0).optional(),
maximum: z.number().optional(),
exclusiveMaximum: z.number().optional(),
minimum: z.number().optional(),
exclusiveMinimum: z.number().optional(),
maxLength: NonNegativeInteger.optional(),
minLength: NonNegativeIntegerDefault0.optional(),
pattern: z.string().optional(),
get additionalItems() {
return CoreSchemaMetaSchema.optional();
},
items: z
.union([
z.lazy<typeof CoreSchemaMetaSchema>(() => CoreSchemaMetaSchema),
SchemaArray,
])
.default(true),
maxItems: NonNegativeInteger.optional(),
minItems: NonNegativeIntegerDefault0.optional(),
uniqueItems: z.boolean().default(false),
get contains() {
return CoreSchemaMetaSchema.optional();
},
maxProperties: NonNegativeInteger.optional(),
minProperties: NonNegativeIntegerDefault0.optional(),
required: StringArray.optional(),
get additionalProperties() {
return CoreSchemaMetaSchema.optional();
},
definitions: z
.object({})
.catchall(
z.lazy<typeof CoreSchemaMetaSchema>(() => CoreSchemaMetaSchema)
)
.default({}),
properties: z
.object({})
.catchall(
z.lazy<typeof CoreSchemaMetaSchema>(() => CoreSchemaMetaSchema)
)
.default({}),
patternProperties: z
.object({})
.catchall(
z.lazy<typeof CoreSchemaMetaSchema>(() => CoreSchemaMetaSchema)
)
.default({}),
dependencies: z
.object({})
.catchall(
z.lazy<typeof CoreSchemaMetaSchema>(() =>
z.union([CoreSchemaMetaSchema, StringArray])
)
)
.optional(),
get propertyNames() {
return CoreSchemaMetaSchema.optional();
},
const: z.unknown().optional(),
enum: z.array(z.unknown()).min(1).optional(),
type: z.union([SimpleTypes, z.array(SimpleTypes).min(1)]).optional(),
format: z.string().optional(),
contentMediaType: z.string().optional(),
contentEncoding: z.string().optional(),
get if() {
return CoreSchemaMetaSchema.optional();
},
get then() {
return CoreSchemaMetaSchema.optional();
},
get else() {
return CoreSchemaMetaSchema.optional();
},
allOf: SchemaArray.optional(),
anyOf: SchemaArray.optional(),
oneOf: SchemaArray.optional(),
get not() {
return CoreSchemaMetaSchema.optional();
},
}),
z.boolean(),
])
.default(true)
.meta({ title: 'Core schema meta-schema' });
export type CoreSchemaMetaSchema = z.infer<typeof CoreSchemaMetaSchema>;It looks pretty good overall, but there's a small issue I’m not sure how to solve — TypeScript throws an error due to a circular reference: Do you have any idea how this could be fixed? Thanks a lot in advance for any help or suggestions! |
Beta Was this translation helpful? Give feedback.
Replies: 3 comments 3 replies
-
|
Hi @MatejBransky! I'm Dosu and I’m helping the zod team. This is a known TypeScript limitation when dealing with recursive Zod schemas. Zod supports circular references using To fix this, you need to use explicit type annotations and sometimes split your schema into separate declarations. Here are some actionable tips:
Let me know if you need a concrete rewrite of your schema with these fixes! If this solves your problem, please close the issue. To reply, just mention @dosu. How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other |
Beta Was this translation helpful? Give feedback.
-
|
I tried applying the solution that @vladyslav-n shared here #4691 a week ago — and it looks like it works! Thank you Vladyslav! 🙏 Working Zod schema for JSON Schemaimport { z } from 'zod/v4';
export const NonNegativeInteger = z.int().min(0);
export type NonNegativeInteger = z.infer<typeof NonNegativeInteger>;
export const NonNegativeIntegerDefault0 = z.intersection(
NonNegativeInteger,
z.number().default(0)
);
export type NonNegativeIntegerDefault0 = z.infer<
typeof NonNegativeIntegerDefault0
>;
export const StringArray = z.array(z.string()).default([]);
export type StringArray = z.infer<typeof StringArray>;
export const SimpleTypes = z.enum([
'array',
'boolean',
'integer',
'null',
'number',
'object',
'string',
]);
export type SimpleTypes = z.infer<typeof SimpleTypes>;
// Base schema for the core schema meta-schema
const CoreSchemaMetaSchemaBase = z.object({
$id: z.string().optional(),
$schema: z.url().optional(),
$ref: z.string().optional(),
$comment: z.string().optional(),
title: z.string().optional(),
description: z.string().optional(),
default: z.unknown().optional(),
readOnly: z.boolean().default(false),
writeOnly: z.boolean().default(false),
examples: z.array(z.unknown()).optional(),
multipleOf: z.number().gt(0).optional(),
maximum: z.number().optional(),
exclusiveMaximum: z.number().optional(),
minimum: z.number().optional(),
exclusiveMinimum: z.number().optional(),
maxLength: NonNegativeInteger.optional(),
minLength: NonNegativeIntegerDefault0.optional(),
pattern: z.string().optional(),
maxItems: NonNegativeInteger.optional(),
minItems: NonNegativeIntegerDefault0.optional(),
uniqueItems: z.boolean().default(false),
maxProperties: NonNegativeInteger.optional(),
minProperties: NonNegativeIntegerDefault0.optional(),
required: StringArray.optional(),
const: z.unknown().optional(),
enum: z.array(z.unknown()).min(1).optional(),
type: z.union([SimpleTypes, z.array(SimpleTypes).min(1)]).optional(),
format: z.string().optional(),
contentMediaType: z.string().optional(),
contentEncoding: z.string().optional(),
});
// Types for input/output with recursion
type CoreSchemaMetaSchemaInput =
| boolean
| (z.input<typeof CoreSchemaMetaSchemaBase> & {
items?: CoreSchemaMetaSchemaInput | CoreSchemaMetaSchemaInput[];
additionalItems?: CoreSchemaMetaSchemaInput;
contains?: CoreSchemaMetaSchemaInput;
additionalProperties?: CoreSchemaMetaSchemaInput;
definitions?: Record<string, CoreSchemaMetaSchemaInput>;
properties?: Record<string, CoreSchemaMetaSchemaInput>;
patternProperties?: Record<string, CoreSchemaMetaSchemaInput>;
dependencies?: CoreSchemaMetaSchemaInput | string[];
propertyNames?: CoreSchemaMetaSchemaInput;
if?: CoreSchemaMetaSchemaInput;
then?: CoreSchemaMetaSchemaInput;
else?: CoreSchemaMetaSchemaInput;
allOf?: CoreSchemaMetaSchemaInput[];
anyOf?: CoreSchemaMetaSchemaInput[];
oneOf?: CoreSchemaMetaSchemaInput[];
not?: CoreSchemaMetaSchemaInput;
})
| undefined;
type CoreSchemaMetaSchemaBaseOutput = z.output<typeof CoreSchemaMetaSchemaBase>;
type CoreSchemaMetaSchemaOutput =
| boolean
| (CoreSchemaMetaSchemaBaseOutput & {
items: CoreSchemaMetaSchemaOutput | CoreSchemaMetaSchemaOutput[];
additionalItems?: CoreSchemaMetaSchemaOutput;
contains?: CoreSchemaMetaSchemaOutput;
additionalProperties?: CoreSchemaMetaSchemaOutput;
definitions: Record<string, CoreSchemaMetaSchemaOutput>;
properties: Record<string, CoreSchemaMetaSchemaOutput>;
patternProperties: Record<string, CoreSchemaMetaSchemaOutput>;
dependencies?: CoreSchemaMetaSchemaOutput | string[];
propertyNames?: CoreSchemaMetaSchemaOutput;
if?: CoreSchemaMetaSchemaOutput;
then?: CoreSchemaMetaSchemaOutput;
else?: CoreSchemaMetaSchemaOutput;
allOf?: CoreSchemaMetaSchemaOutput[];
anyOf?: CoreSchemaMetaSchemaOutput[];
oneOf?: CoreSchemaMetaSchemaOutput[];
not?: CoreSchemaMetaSchemaOutput;
});
export const CoreSchemaMetaSchema: z.ZodType<
CoreSchemaMetaSchemaOutput,
CoreSchemaMetaSchemaInput
> = z
.union([
CoreSchemaMetaSchemaBase.extend({
get items() {
return z
.union([CoreSchemaMetaSchema, z.array(CoreSchemaMetaSchema).min(1)])
.default(true);
},
get additionalItems() {
return CoreSchemaMetaSchema.optional();
},
get contains() {
return CoreSchemaMetaSchema.optional();
},
get additionalProperties() {
return CoreSchemaMetaSchema.optional();
},
get definitions() {
return z.record(z.string(), CoreSchemaMetaSchema).default({});
},
get properties() {
return z.object({}).catchall(CoreSchemaMetaSchema).default({});
},
get patternProperties() {
return z.object({}).catchall(CoreSchemaMetaSchema).default({});
},
get dependencies() {
return z.union([CoreSchemaMetaSchema, StringArray]).optional();
},
get propertyNames() {
return CoreSchemaMetaSchema.optional();
},
get if() {
return CoreSchemaMetaSchema.optional();
},
get then() {
return CoreSchemaMetaSchema.optional();
},
get else() {
return CoreSchemaMetaSchema.optional();
},
get allOf() {
return z.array(CoreSchemaMetaSchema).min(1).optional();
},
get anyOf() {
return z.array(CoreSchemaMetaSchema).min(1).optional();
},
get oneOf() {
return z.array(CoreSchemaMetaSchema).min(1).optional();
},
get not() {
return CoreSchemaMetaSchema.optional();
},
}),
z.boolean(),
])
.default(true)
.meta({ title: 'Core schema meta-schema' });
export type CoreSchemaMetaSchema = z.infer<typeof CoreSchemaMetaSchema>;Unfortunately, the approach by @colinhacks is missing proper type inference for circular references. Vladyslav’s solution is quite sophisticated, though. I’m curious how to handle this in the parser without having to completely restructure the output in such cases. Originally, the parser and generator worked bit by bit, but now it requires full context awareness in order to decide what can actually be generated. Alternatively, I might just start generating schemas in this more verbose style by default. 🤔 If anyone has an idea on how this could be solved in a simpler way, I’m all ears! Otherwise, consider this resolved. ...And now I can happily continue with the implementation. |
Beta Was this translation helpful? Give feedback.
-
|
@MatejBransky is this something you're planning on publishing? And if so, how similar is it to something like https://www.npmjs.com/package/json-schema-to-zod? |
Beta Was this translation helpful? Give feedback.
I tried applying the solution that @vladyslav-n shared here #4691 a week ago — and it looks like it works! Thank you Vladyslav! 🙏
Working Zod schema for JSON Schema