colinhacks/zod · error · Error

Circular reference not resolved

Error message

Circular reference not resolved: ${refPath}

What it means

Thrown lazily (packages/zod/src/v4/classic/from-json-schema.ts:180) at parse time — not conversion time — when a circular $ref was detected during conversion and wrapped in z.lazy(), but when the lazy actually evaluates the referenced schema was never registered in ctx.refs. The lazy closure expects a previous conversion pass to have populated the map; if the cycle broke before that happened (e.g. the ref's target itself failed to convert), the closure throws.

Solutions

  1. Inspect the refPath in the message — it names the $ref whose target never resolved; open that definition and check it converts cleanly in isolation.
  2. Remove or rewrite any unsupported keywords on the referenced definition so its conversion completes and ctx.refs is populated.
  3. Verify the recursive $ref actually points to a valid #/$defs/Name entry that can be converted standalone.
  4. If the cycle is intentional but unsupported, hand-author the recursive Zod schema with z.lazy(() => schema) referencing a declared variable.

Example fix

// before — recursive ref to a definition that fails to convert
const schema = {
  $defs: {
    Node: {
      type: 'object',
      properties: { children: { type: 'array', items: { $ref: '#/$defs/Node' } } },
      if: { properties: { leaf: { const: true } } }, // unsupported keyword breaks conversion
      then: {},
    },
  },
  $ref: '#/$defs/Node',
};

// after — remove the unsupported keyword so the cycle resolves
const schema = {
  $defs: {
    Node: {
      type: 'object',
      properties: {
        leaf: { type: 'boolean' },
        children: { type: 'array', items: { $ref: '#/$defs/Node' } },
      },
    },
  },
  $ref: '#/$defs/Node',
};
Defensive patterns

Strategy: try-catch

Validate before calling

// Pre-validate that every recursive $ref's target converts standalone.
function assertRecursiveRefsConvertible(root: any, version: 'draft-2020-12' | 'draft-7', fromJSONSchema: (s: any) => unknown) {
  const defsKey = version === 'draft-2020-12' ? '$defs' : 'definitions';
  const defs = root?.[defsKey] ?? {};
  for (const [name, sub] of Object.entries(defs) as [string, any][]) {
    try {
      // convert the definition WITHOUT its siblings to surface failures
      fromJSONSchema({ ...sub, $defs: defs });
    } catch (e) {
      throw new Error(`Definition '${name}' (target of a possible recursive ref) fails to convert: ${(e as Error).message}`);
    }
  }
}

Type guard

null

Try / catch

// Wrap parse of a recursive schema so the lazy-eval failure is observable.
try {
  result = zodSchema.parse(data);
} catch (e) {
  if (e instanceof Error && /Circular reference not resolved/.test(e.message)) {
    // the refPath in the message names the unresolved target;
    // inspect that definition for unsupported keywords.
    console.error('Recursive conversion broke at:', e.message);
  }
  throw e;
}

Prevention

When it happens

Trigger: Calling fromJSONSchema on a schema with a self-referential or mutually-recursive $ref (e.g. a tree node whose children reference the node itself), where the resolution of the referenced definition failed or the referenced path is invalid, so ctx.refs never receives the resolved ZodType. The throw surfaces only when the resulting schema is later used to parse data.

Common situations: Recursive schemas (linked lists, trees, graphs) where a nested $ref points at a definition that itself has an unsupported keyword, causing its conversion to fail mid-cycle; broken recursive references that look correct at conversion but reference a non-convertible subschema; cycles that pass through an unsupported construct.

Related errors


AI-assisted analysis of colinhacks/zod@2d90846af9 (2026-08-11). Data as JSON: /api/errors/2d21be4087dd5881. Report an issue: GitHub.

Appendix: source

Thrown at packages/zod/src/v4/classic/from-json-schema.ts:180

  if (schema.if !== undefined || schema.then !== undefined || schema.else !== undefined) {
    throw new Error("Conditional schemas (if/then/else) are not supported");
  }
  if (schema.dependentSchemas !== undefined || schema.dependentRequired !== undefined) {
    throw new Error("dependentSchemas and dependentRequired are not supported");
  }

  // Handle $ref
  if (schema.$ref) {
    const refPath = schema.$ref;
    if (ctx.refs.has(refPath)) {
      return ctx.refs.get(refPath)!;
    }

    if (ctx.processing.has(refPath)) {
      // Circular reference - use lazy
      return z.lazy(() => {
        if (!ctx.refs.has(refPath)) {
          throw new Error(`Circular reference not resolved: ${refPath}`);
        }
        return ctx.refs.get(refPath)!;
      });
    }

    ctx.processing.add(refPath);
    const resolved = resolveRef(refPath, ctx);
    const zodSchema = convertSchema(resolved, ctx);
    ctx.refs.set(refPath, zodSchema);
    ctx.processing.delete(refPath);
    return zodSchema;
  }

  // Handle enum
  if (schema.enum !== undefined) {
    const enumValues = schema.enum;

    // Special case: OpenAPI 3.0 null representation { type: "string", nullable: true, enum: [null] }

View on GitHub (pinned to 2d90846af9)