Skip to content

Duplicate shape detection

Strict graph mode flags inline anonymous shapes that duplicate a registered named schema. The intent is drift prevention: if { type: 'string', format: 'email' } exists inline in five schemas and you later tighten the format constraint, you must find and update all five occurrences by hand. Extract it to a named EmailSchema and there is one place to change.

In default strict-graph mode, the structure guard runs first and throws SCHEMA_STRUCTURE_INVALID when an inline constrained shape is encountered — regardless of whether it structurally duplicates a named schema. SCHEMA_DUPLICATE_SHAPE is the code raised by findDuplicates() when running in permissive mode (enableStrictGraph: false) and an inline shape matches a registered named schema exactly.

This page explains what triggers detection, the nominal-subclass behaviour, and the escape hatches available when legitimate structural coincidence is not drift.

Runnable example

/**
 * Duplicate detection — runnable example
 * Demonstrates two observable detection modes:
 *
 *   1. Default strict-graph mode: throws SCHEMA_STRUCTURE_INVALID when an
 *      inline anonymous sub-shape is encountered. Inline shapes require $ref —
 *      extract the shape to a named schema with a $id.
 *
 *   2. enableStrictGraph: false — accepts the registration silently; findDuplicates()
 *      returns the (schemaId, pointer, equivalentTo, shape) report for the caller
 *      to act on (e.g. as a CI gate: log duplicates and exit non-zero).
 *
 * Scenario: IsbnSchema is the canonical named primitive for 13-digit ISBN strings.
 * BookWithInlineIsbn inlines the same constraint as a property shape instead of
 * using a $ref. Both detection modes surface this structural coincidence.
 */

import { JsonTology } from '../../../src/index.js';
import { SchemaRegistry } from '../../../src/modules/registry/SchemaRegistry.js';

// The canonical named ISBN primitive.
const IsbnSchema = {
  '$id': 'urn:bookstore:DupDetectionIsbn',
  'pattern': '^\\d{13}$',
  'type': 'string'
} as const;

// A book schema that inlines the same ISBN constraint instead of $ref-ing IsbnSchema.
// The inline property shape `{ pattern: '^\\d{13}$', type: 'string' }` is
// structurally identical to IsbnSchema — this is the duplicate.
const BookWithInlineIsbn = {
  '$id': 'urn:bookstore:DupDetectionBook',
  'properties': {
    'isbn': {
      'pattern': '^\\d{13}$',
      'type': 'string'
    },
    'title': { 'type': 'string' }
  },
  'type': 'object'
} as const;

// Mode 1: Default strict-graph mode throws SCHEMA_STRUCTURE_INVALID.
// Strict graph rejects inline shapes regardless of whether they duplicate a
// named schema — the intent is to keep every constrained primitive as a $ref.
let strictModeThrew = false;

try {
  JsonTology.create({
    'baseIri': 'https://bookstore.example',
    'schemas': [
      IsbnSchema,
      BookWithInlineIsbn
    ] as const
  });
} catch (error) {
  const schemaErr = error as { 'code'?: string };

  strictModeThrew = schemaErr.code === 'SCHEMA_STRUCTURE_INVALID';
}

console.assert(
  strictModeThrew,
  'strict-graph mode throws SCHEMA_STRUCTURE_INVALID for inline primitive shapes'
);

// Mode 2: permissive mode accepts the registration; findDuplicates() reports the pair.
// Use this in CI to detect structural coincidence without blocking development:
//   if (registry.findDuplicates().length > 0) process.exit(1)
const registry = new SchemaRegistry({ 'enableStrictGraph': false });

registry.set(IsbnSchema);
registry.set(BookWithInlineIsbn);

const duplicates = registry.findDuplicates();

console.assert(duplicates.length > 0, 'findDuplicates returns at least one duplicate entry');

const entry = duplicates[0];

if (entry === undefined) {
  throw new Error('expected at least one duplicate entry');
}

console.assert(
  entry.schemaId === BookWithInlineIsbn.$id,
  'entry.schemaId is the schema containing the duplicate inline shape'
);
console.assert(
  typeof entry.pointer === 'string',
  'entry.pointer is a JSON pointer to the inline shape within schemaId'
);
console.assert(
  entry.equivalentTo === IsbnSchema.$id,
  'entry.equivalentTo points to the uniquely-authoritative named IsbnSchema'
);
// entry.shape is the raw inline sub-schema object (always present on the
// DuplicateReportEntryType — confirmed non-nullable by the type system).
void entry.shape;

console.log(
  'SCHEMA_STRUCTURE_INVALID (strict):',
  strictModeThrew,
  '| findDuplicates length:',
  duplicates.length,
  '| schemaId:',
  entry.schemaId,
  '| pointer:',
  entry.pointer,
  '| equivalentTo:',
  entry.equivalentTo
);
Output
Press Execute to run this example against the real library.

What triggers SCHEMA_DUPLICATE_SHAPE

Detection fires when an anonymous inline sub-shape inside a registered schema has the same structural hash as a registered top-level schema. "Structural hash" strips $id, title, description, $comment, and examples before hashing — everything else (including type, format, pattern, minimum, properties, required) is included.

Example that triggers the error:

ts
import { JsonTology } from 'json-tology';
import { EmailSchema } from './bookstore/entities/Email.js';
import { CustomerSchema } from './bookstore/entities/Customer.js';

// Suppose CustomerSchema has an inline anonymous email shape instead of $ref:
// properties: { email: { type: 'string', format: 'email' } }  ← not a $ref
// and EmailSchema is { $id: 'urn:bookstore:Email', type: 'string', format: 'email' }
// Detection fires because the inline shape hashes identically to EmailSchema.

const entities = JsonTology.create({
  baseIri: 'urn:bookstore:',
  schemas: [EmailSchema, CustomerSchema] as const,
});
// throws SchemaError('SCHEMA_DUPLICATE_SHAPE') if CustomerSchema has inline email shape

The correct form uses $ref:

ts
// In Customer.ts — reference the named schema, don't inline its shape
properties: {
  email: { $ref: EmailSchema.$id }
}

Nominal-subclass pairs: no longer flagged

A common false-positive concern is a domain with multiple named nominal primitives that erase to the same base type. For example, the bookstore domain has CustomerId and OrderId — both are { type: 'string', format: 'uuid' }. They are intentionally distinct nominal classes; they are not structural drift.

Since the nominal-aware deduplication change, two top-level registered schemas with the same structural hash no longer flag each other's inline occurrences. The algorithm is:

  1. For each registered top-level schema, compute a nominal-aware hash (StructuralHash.of(schema) + ':t' for transform-bearing schemas, + ':p' for plain schemas).
  2. Build a match cache from hashes that appear for exactly one top-level schema. Hashes that appear for two or more top-level schemas are omitted — they represent intentional nominal variety, not a unique authoritative source.
  3. Walk inline sub-shapes of every registered schema. Report an inline shape only if its hash matches an entry in the match cache (a hash with exactly one authoritative top-level owner).

This means CustomerId and OrderId (both { type: 'string', format: 'uuid' }) are registered, their hash is contested (two top-level owners), and inline occurrences of { type: 'string', format: 'uuid' } are not reported — neither named schema is the uniquely authoritative owner of that shape.

Additionally, transform-bearing schemas are separated from plain schemas by the :t / :p suffix. A plain { type: 'string' } schema does not collide with a { type: 'string' } schema that carries a registered Transform decoder.

Escape hatches

1. enableStrictGraph: false, enableDuplicateDetection: false, enableInlineWarnings: false

These three flags control enforcement strictness across the registry; see Strict graph mode for the full explanation of each — enableStrictGraph downgrades all violations to warnings, enableDuplicateDetection: false disables the automatic duplicate scan, and enableInlineWarnings: false suppresses inline-shape warnings.

2. format as semantic typing — a legitimate structural differentiator

format is a standard JSON Schema keyword for semantic type annotation. Using it to distinguish schemas that share the same base type is not a hack — it is the standard JSON Schema approach.

ts
// Two string schemas with different format values hash differently.
// Neither flags the other's inline occurrences.
const CustomerIdSchema = {
  $id: 'urn:bookstore:CustomerId',
  type: 'string',
  format: 'uuid',
} as const;

const SlugSchema = {
  $id: 'urn:bookstore:Slug',
  type: 'string',
  format: 'slug',   // different format → different structural hash
} as const;

Because format is included in the structural hash, two { type: 'string' } schemas that differ only in format have different hashes and do not contest each other. Register both; neither flags inline occurrences of the other.

This is the right approach when your nominal primitive types have genuinely different semantic types. If two schemas really are structurally and semantically identical, they should be the same schema.

On-demand audit with findDuplicates()

registry.findDuplicates() is available at any time regardless of the current mode. Use it in a CI script to audit an existing schema set before enabling strict mode:

ts
import { JsonTology } from 'json-tology';
import * as schemas from './bookstore/index.js';

const entities = JsonTology.create({
  baseIri: 'urn:bookstore:',
  schemas: Object.values(schemas) as const,
  enableStrictGraph: false,
});

const duplicates = entities.registry.findDuplicates();

if (duplicates.length > 0) {
  for (const dup of duplicates) {
    console.error(`${dup.schemaId}#${dup.pointer} duplicates ${dup.equivalentTo}`);
  }
  process.exit(1);
}

Run this in CI as a ratchet: fix duplicates one at a time, then enable enableStrictGraph: true once the list is clear.

Migrating to strict mode

See Strict graph mode - migrating existing schemas for the full step-by-step walkthrough.

Released under the MIT License.