colinhacks/zod · error · Error

Circular reference not resolved: ${refPath}

Error message

Circular reference not resolved: ${refPath}

What it means

Thrown inside the z.lazy() thunk emitted for a circular `$ref`. The converter defers recursive refs by returning a lazy schema that reads `ctx.refs.get(refPath)` on demand; if, at evaluation time, the ref was never populated (e.g. the cycle could not be resolved through `$defs`), the thunk throws.

Source

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

  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 912f0f51b0)

Solutions

  1. Ensure the recursive target exists under `$defs`/`definitions` with the exact path used by the `$ref`.
  2. Verify the `$ref` fragment is in `#/$defs/<Name>` form (or `#/definitions/<Name>` for draft-7).
  3. Test conversion with sample data that triggers the recursion early to surface the failure during development.

Example fix

// before
{ "$ref": "#/$defs/Node" } // $defs.Node missing or mis-named

// after
{
  "$defs": {
    "Node": {
      "type": "object",
      "properties": { "children": { "type": "array", "items": { "$ref": "#/$defs/Node" } } }
    }
  },
  "$ref": "#/$defs/Node"
}
Defensive patterns

Strategy: validation

Validate before calling

function assertRecursiveRefsResolve(schema: any) {
  const defs = schema.$defs ?? schema.definitions ?? {};
  const visit = (n: any, seen = new Set<string>()): boolean => {
    if (!n || typeof n !== "object") return true;
    if (typeof n.$ref === "string") {
      const key = n.$ref.split("/").pop();
      if (seen.has(n.$ref)) return key in defs; // cycle: target must exist
      if (!defs[key]) return false;
      return visit(defs[key], new Set(seen).add(n.$ref));
    }
    return Object.values(n).every((v) => visit(v, new Set(seen)));
  };
  if (!visit(schema)) throw new Error("Recursive $ref target missing");
}

Type guard

function refTargetExists(schema: any, ref: string): boolean {
  const defs = schema.$defs ?? schema.definitions ?? {};
  return ref.split("/").pop()! in defs;
}

Prevention

When it happens

Trigger: A self-referential or mutually recursive `$ref` whose target is not present in `$defs`/`definitions` (so resolveRef already failed), or a ref graph the converter's single-pass processing set could not close. Typically surfaces only when the returned Zod schema is actually used to parse data that exercises the recursion.

Common situations: Tree/node schemas with `#/$defs/Node` referencing itself; recursive OpenAPI components where the path is wrong; converting partial schemas whose `$defs` were stripped.

Related errors


AI-assisted analysis of colinhacks/zod@912f0f51b0 (2026-08-03). Data as JSON: /data/errors/2d21be4087dd5881.json. Report an issue: GitHub.