Compose.equivalent Compile-time
Validation modes: Validation modes reference
Declaration. Creates a new schema with a different $id that references the source schema via $ref. The two schemas are structurally identical - they validate the same data - but serve different semantic roles in the domain model. In the OWL TBox, owl:equivalentClass is emitted automatically. In SHACL, the new schema gains sh:node pointing at the source.
Compose.equivalent(source, options): { $id, $ref, description?, title?, examples? }Use this when you want to give a domain-distinct name to an existing schema without duplicating its structure. The canonical use is giving purpose-specific aliases to shared primitives - PrimaryIsbn wrapping Isbn, CatalogPrice wrapping Money: where the two names carry different semantic intent but must validate identically.
Don't use this when the two schemas have different structure. If PrimaryIsbn had an extra constraint (e.g. must start with 978), it is NOT equivalent to Isbn: use Compose.extend or a new standalone schema instead.
Self-equivalence prevention Compile-time
options.$id cannot equal source.$id. A self-equivalent declaration surfaces a SelfEquivalentType brand error at the call site.
/**
* Anti-pattern: a self-equivalent declaration where `options.$id` equals
* `source.$id`. The compiler surfaces a `SelfEquivalentType` brand at the
* call site so the mistake fails to compile.
*/
import { Compose } from '../../../src/index.js';
import { IsbnSchema } from '../bookstore/index.js';
// ✗ Compile error — `options.$id` collides with `source.$id`.
// @ts-expect-error SelfEquivalentType brand fires on $id collision
const _Bad = Compose.equivalent(IsbnSchema, { '$id': IsbnSchema.$id });
console.log('Self-equivalent anti-pattern: options.$id must differ from source.$id:', IsbnSchema.$id, '| _Bad.$id:', _Bad.$id);
Examples
Example 1: Domain alias for a primitive schema
Give the shared IsbnSchema a catalog-specific name. The two schemas validate identically; the alias carries the catalog-facing description.
/**
* Compose.equivalent — Example 1: Domain alias for the canonical Isbn primitive
*
* Demonstrates `Compose.equivalent`: produces a new `$id` that
* `$ref`s the source. Two structurally-identical schemas with
* distinct domain names — `PrimaryIsbn` for catalog lookup, the
* canonical `Isbn` for everything else. OWL emits
* `owl:equivalentClass`, SHACL emits `sh:node`.
*
* The alias registers onto the canonical bookstore via
* `jt.set()`, so every call goes through the same
* registry the rest of the docs reference.
*/
import { Compose } from '../../../src/index.js';
import {
aboxFixtures, createBookstoreDocRegistry,
IsbnSchema
} from '../bookstore/index.js';
// createBookstoreDocRegistry seeds a permissive copy of the bookstore — docs examples extend
// it with ad-hoc demo schemas; strict-graph checking is intentionally off here.
const jt = createBookstoreDocRegistry();
const PrimaryIsbnSchema = Compose.equivalent(IsbnSchema, {
'$id': 'https://bookstore.example/PrimaryIsbn',
'description': 'The canonical ISBN used for catalog lookup and ordering.'
} as const);
const jt2 = jt.set(PrimaryIsbnSchema);
// The canonical Bastian-ordered ISBN validates against both schemas.
const isbn = aboxFixtures.rareBook.isbn;
const sourceErrs = jt2.validate(IsbnSchema.$id, isbn);
const aliasErrs = jt2.validate(PrimaryIsbnSchema.$id, isbn);
console.assert(sourceErrs.length === 0);
console.assert(aliasErrs.length === 0);
// A malformed ISBN fails identically through both names.
const badIsbn = 'not-an-isbn';
const sourceBad = jt2.validate(IsbnSchema.$id, badIsbn);
const aliasBad = jt2.validate(PrimaryIsbnSchema.$id, badIsbn);
console.assert(sourceBad.length === aliasBad.length);
console.assert(sourceBad.length > 0);
console.log('Isbn and PrimaryIsbn validate identically:', sourceErrs.length === aliasErrs.length, '| bad ISBN fails both:', sourceBad.length, 'error(s)');
In the OWL TBox:
{ "@id": "https://bookstore.example/PrimaryIsbn", "owl:equivalentClass": { "@id": "https://bookstore.example/Isbn" } }Example 2: Named alias in a composed schema set
Register the alias alongside the source so both IDs are available to validate and instantiate.
/**
* Compose.equivalent — Example 2: Named alias in a composed schema set
*
* Registers the alias alongside the source so both IDs are available
* to validate and instantiate. The two schemas validate identically;
* the catalog-facing alias carries its own description for catalog
* tooling.
*/
import { Compose } from '../../../src/index.js';
import {
aboxFixtures, createBookstoreDocRegistry,
IsbnSchema
} from '../bookstore/index.js';
// createBookstoreDocRegistry seeds a permissive copy of the bookstore — docs examples extend
// it with ad-hoc demo schemas; strict-graph checking is intentionally off here.
const jt = createBookstoreDocRegistry();
const CatalogIsbnSchema = Compose.equivalent(IsbnSchema, {
'$id': 'https://bookstore.example/CatalogIsbn',
'description': 'ISBN as used in the public catalog feed.'
} as const);
const jt2 = jt.set(CatalogIsbnSchema);
// Both IDs validate the canonical Neverending Story ISBN identically.
const isbn = aboxFixtures.rareBook.isbn;
const sourceResult = jt2.validate(IsbnSchema.$id, isbn);
const aliasResult = jt2.validate(CatalogIsbnSchema.$id, isbn);
console.assert(sourceResult.ok);
console.assert(aliasResult.ok);
console.log('Isbn validates:', sourceResult.ok, '| CatalogIsbn validates:', aliasResult.ok, '| isbn:', isbn);
Example 3: OWL equivalence in the emitted TBox
/**
* Compose.equivalent — Example 3: OWL equivalence in the emitted TBox
*
* `Compose.equivalent` emits `owl:equivalentClass` between the source
* and alias `$id`. The TBox carries both classes; a reasoner can fold
* facts from one onto the other.
*/
import { Compose } from '../../../src/index.js';
import {
createBookstoreDocRegistry,
IsbnSchema
} from '../bookstore/index.js';
// createBookstoreDocRegistry seeds a permissive copy of the bookstore — docs examples extend
// it with ad-hoc demo schemas; strict-graph checking is intentionally off here.
const jt = createBookstoreDocRegistry();
const PrimaryIsbnSchema = Compose.equivalent(IsbnSchema, { '$id': 'https://bookstore.example/PrimaryIsbn' } as const);
jt.set(PrimaryIsbnSchema);
const tbox = JSON.parse(jt.toTbox().jsonLd()) as {
'@graph'?: ReadonlyArray<Record<string, unknown>>;
};
// The PrimaryIsbn node appears in the TBox graph, linked to Isbn via
// owl:equivalentClass.
const graph = tbox['@graph'] ?? [];
const primaryNode = graph.find((node) => {
return node['@id'] === 'https://bookstore.example/PrimaryIsbn';
});
console.assert(primaryNode !== undefined);
console.log('PrimaryIsbn node in TBox:', primaryNode?.['@id']);
console.log('equivalentClass:', primaryNode?.['owl:equivalentClass']);
Bad examples: what NOT to do
Anti-pattern 1: Using equivalent when the new schema adds a constraint
/**
* Compose.equivalent — Anti-pattern 1: Adding a constraint to an alias
*
* Equivalent expresses class identity. Adding a constraint makes the
* new schema structurally different from the source, so equivalent is
* semantically wrong. Use `Compose.extend` (or a standalone schema)
* when the alias adds a constraint.
*/
import { Compose } from '../../../src/index.js';
import { IsbnSchema } from '../bookstore/index.js';
// ✓ Do this — extend layers the additional pattern on top of Isbn.
const Isbn978Schema = Compose.extend(
IsbnSchema,
{ 'pattern': '^978' } as const,
'https://bookstore.example/Isbn978'
);
const isbn978Id: string = Isbn978Schema.$id;
console.assert(isbn978Id.endsWith('Isbn978'));
console.log('Isbn978 extends Isbn with pattern constraint:', isbn978Id);
Anti-pattern 2: Registering only the alias, not the source
/**
* Compose.equivalent — Anti-pattern 2: Registering only the alias
*
* `CatalogIsbn` `$ref`s `Isbn`, so the source must be registered
* alongside the alias. Otherwise ref resolution surfaces a
* `GraphError` on first use of the alias.
*/
import {
Compose, JsonTology
} from '../../../src/index.js';
import { IsbnSchema } from '../bookstore/index.js';
const CatalogIsbnSchema = Compose.equivalent(IsbnSchema, { '$id': 'https://bookstore.example/CatalogIsbn' } as const);
// ✓ Register the source alongside the alias so $ref resolution succeeds.
const jt = JsonTology.create({
'baseIri': 'https://bookstore.example',
'schemas': [
IsbnSchema,
CatalogIsbnSchema
] as const
});
const result = jt.validate(CatalogIsbnSchema.$id, '9783522128001');
console.assert(result.ok);
console.log('CatalogIsbn validates when source Isbn is registered alongside it:', result.ok);
Anti-pattern 3: Using equivalent to rename a class in place
/**
* Compose.equivalent — Anti-pattern 3: Using equivalent to rename in place
*
* If the original name is no longer needed, do not alias it. Change
* the source schema's `$id` and update references instead. Two names
* for the same class create registry drift.
*
* The constructive shape: equivalent is for the case where both names
* must coexist in the domain model (catalog feed alias + canonical
* primitive). Renaming-in-place is structural refactoring, not OWL
* equivalence.
*/
import { IsbnSchema } from '../bookstore/index.js';
// The canonical Isbn schema keeps its $id. Use it directly rather
// than building a sibling alias whose only purpose is to rename.
const canonicalId: string = IsbnSchema.$id;
console.assert(canonicalId.length > 0);
console.log('Use the canonical $id directly, do not create a rename alias:', canonicalId);
Comparison
const CatalogIsbn = Compose.equivalent(IsbnSchema, {
$id: 'https://bookstore.example/CatalogIsbn',
description: 'ISBN as used in the public catalog feed.',
});
// Emits owl:equivalentClass in the TBox; sh:node in SHACL output.// Zod has no aliasing primitive. Create a branded type or simply re-use the schema:
const CatalogIsbn = IsbnSchema; // structural alias only, no semantic distinction
// Limitation: no OWL/SHACL output; no $ref-backed graph identity.import * as v from 'valibot';
// Valibot has no aliasing primitive. Wrap with a pipe or simply re-assign:
const CatalogIsbn = IsbnSchema; // same object reference — no distinct schema ID
// Limitation: no graph representation; cannot emit owl:equivalentClass.// io-ts: rename via a codec wrapper — structural only, no semantic identity
const CatalogIsbn = IsbnCodec; // same codec reference
// Limitation: no $ref-backed identity; no OWL output.// AJV: register the same schema under a second ID:
ajv.addSchema({ ...IsbnSchema, $id: 'https://bookstore.example/CatalogIsbn' });
// Limitation: copies the schema object; no owl:equivalentClass emitted;
// structural drift possible if the copy diverges.# Python: subclass with no additions:
class CatalogIsbn(Isbn):
pass
# Limitation: produces a subclass in the Python type system, not an
# equivalent; model_json_schema() does not emit owl:equivalentClass.// Limitation: feature not directly supported in TypeBox. See /comparisons for the matrix.// Limitation: feature not directly supported in Yup. See /comparisons for the matrix.// Limitation: feature not directly supported in Joi. See /comparisons for the matrix.// Limitation: feature not directly supported in Effect Schema. See /comparisons for the matrix.// Limitation: feature not directly supported in ArkType. See /comparisons for the matrix.// Limitation: feature not directly supported in Runtypes. See /comparisons for the matrix.Related
Compose.extend- structural extension (produces allOf+$ref, maps tordfs:subClassOf)Compose.subClassOf- explicit taxonomic subclass with optional multi-parent- Graph concepts (TBox/ABox)
See also
- OWL TBox output
- SHACL output
- Graph-native authoring - why naming reduces drift