Detecting Circular $ref in JSON Schema with Next.js & TypeScript

Learn to detect circular $ref loops in JSON Schema for Next.js—using a Set‑based client check and json-schema-ref-parser for server‑side validation.

Detecting circular references in JSON Schema can be tricky, especially when a $ref points back to the root schema ("#").
Below is a practical guide for a Next.js + TypeScript project that shows two approaches:

  • A lightweight client‑side detector using a simple recursive walk with a Set to remember visited $refs.
  • A production‑grade server‑side solution leveraging the json-schema-ref-parser package, which resolves $refs and automatically throws on circular references.

1️⃣ Why the Problem Matters

{
  "name": "Circular Json Schema",
  "strict": true,
  "schema": {
    "type": "object",
    "properties": {
      "self": { "$ref": "#" }
    }
  }
}
  • "$ref": "#" points to the root schema.
  • Traversing the schema recursively without a guard causes infinite recursion and crashes.
  • Detecting the cycle early lets you either reject the schema or handle it safely (e.g., by limiting depth).

2️⃣ Client‑Side Detection – Manual Traversal

When you need a fast check in the browser (or any environment without Node‑specific packages) you can walk the schema yourself and keep track of already‑seen $ref pointers.

2.1 TypeScript Types

type JSONSchema = {
  $ref?: string;
  type?: string;
  properties?: { [key: string]: JSONSchema };
  items?: JSONSchema | JSONSchema[];
  anyOf?: JSONSchema[];
  allOf?: JSONSchema[];
  oneOf?: JSONSchema[];
  // any additional keywords are allowed
  [key: string]: any;
};

2.2 Core Functions

/**
 * Entry point – returns true if a circular $ref is found.
 */
function hasCircularRef(
  schema: JSONSchema,
  refPath: string = '',
  seenRefs: Set<string> = new Set()
): boolean {
  // Handle a $ref at the current node first
  if (schema.$ref) {
    const target = schema.$ref;

    // If we have already processed this target, we are in a cycle
    if (seenRefs.has(target)) {
      return true;
    }

    // Clone the set for the next recursion level and add the current target
    const nextSeen = new Set(seenRefs);
    nextSeen.add(target);

    // Resolve the reference (this example only knows about "#")
    if (target === '#') {
      // Re‑enter the whole schema – the root contains the same $ref again
      return traverseSchema(schema, nextSeen);
    }

    // For a real implementation you would resolve external/internal pointers here
    // e.g. `resolvePointer(schema, target)`.
    return false; // no cycle detected for unknown refs in this thin version
  }

  // No $ref here → continue depth‑first traversal
  return traverseSchema(schema, seenRefs);
}

/**
 * Recursively walks every object/array property looking for $ref nodes.
 */
function traverseSchema(schema: JSONSchema, seenRefs: Set<string>): boolean {
  if (typeof schema !== 'object' || schema === null) return false;

  for (const key in schema) {
    if (key === '$ref') continue; // already processed above

    const value = schema[key];

    if (Array.isArray(value)) {
      for (const item of value) {
        if (typeof item === 'object' && item !== null) {
          if (hasCircularRef(item, '', seenRefs)) return true;
        }
      }
    } else if (typeof value === 'object' && value !== null) {
      if (hasCircularRef(value, '', seenRefs)) return true;
    }
  }

  return false;
}

2.3 Using the Detector

const schema: JSONSchema = {
  type: 'object',
  properties: {
    self: { $ref: '#' } // circular reference
  }
};

console.log(hasCircularRef(schema)); // → true

What it does

  • Builds a Set<string> of every $ref value encountered.
  • When the same $ref appears again, the function returns true.
  • Works for simple # references and can be extended to support JSON‑Pointer syntax (#/properties/foo) or external URLs.

3️⃣ Server‑Side Detection – json-schema-ref-parser

For robust validation you probably already have an API route that receives user‑provided schemas.
json-schema-ref-parser resolves all $refs (including external files) and throws a clear error on circular references.

3.1 Install

npm install json-schema-ref-parser
# or with yarn:
yarn add json-schema-ref-parser

3.2 API Route Example (Next.js)

// pages/api/validate-schema.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import { bundle } from 'json-schema-ref-parser';

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const schema = req.body; // assume JSON Schema comes in the request body

  try {
    // `bundle` resolves all $refs; throws if a circular reference exists
    await bundle(schema);
    res.status(200).json({ valid: true, message: 'Schema is OK' });
  } catch (err: any) {
    if (err.message?.includes('circular')) {
      res.status(400).json({
        valid: false,
        message: 'Circular reference detected',
        details: err.message,
      });
    } else {
      // Other parsing / validation errors
      res.status(400).json({
        valid: false,
        message: 'Invalid JSON Schema',
        details: err.message,
      });
    }
  }
}

Key points

  • Only works in a Node environment (the library uses fs for external files).
  • Returns a descriptive error like Circular $ref pointer found at "#/properties/self" when a cycle is present.
  • Handles complex cases: $ref to other files, JSON‑Pointer fragments, and even dereferencing of allOf/anyOf/oneOf.

4️⃣ Choosing the Right Tool

Situation Recommended Approach
Simple check on a schema already in memory (client side) Manual traversal with Set (Section 2)
Full validation, possibly with external refs (server side) json-schema-ref-parser.bundle() (Section 3)
Need to dereference the schema for downstream processing json-schema-ref-parser.dereference() – also catches cycles
Want the smallest bundle size for a SPA Write a tiny custom detector and ship only that code

5️⃣ Extending the Manual Detector

If you need to support full JSON‑Pointer resolution without pulling in a heavy library:

  1. Parse the pointer after the leading #. Example: #/properties/child/items.
  2. Walk the schema object according to the path fragments.
  3. Cache resolved fragments in a Map<string, JSONSchema> so each fragment is resolved only once.

You can then replace the simple if (target === '#') branch with a generic resolver:

function resolvePointer(root: JSONSchema, pointer: string): JSONSchema | undefined {
  const parts = pointer.replace(/^#/, '').split('/').filter(Boolean);
  let current: any = root;
  for (const part of parts) {
    if (current && typeof current === 'object') {
      current = current[decodeURIComponent(part)];
    } else {
      return undefined;
    }
  }
  return current as JSONSchema;
}

Combine this resolver with the seenRefs set to detect cycles across any depth.


6️⃣ Wrap‑Up

  • Circular $refs are legitimate in some schemas (e.g., recursive data structures) but they must be detected before naïve recursion.
  • A lightweight client‑side implementation using a Set is easy to drop into any React component or utility file.
  • For server‑side validation or when you need full $ref dereferencing, the battle‑tested json-schema-ref-parser library does the heavy lifting and surfaces clear error messages.
  • Choose the approach that matches your execution context, performance constraints, and validation requirements.

By adding one of these detectors to your Next.js pipeline, you’ll prevent infinite loops, give users immediate feedback on malformed schemas, and keep your application stable. Happy coding!

Made with chatblogr.com