Skip to content

RDF predicates

You only need this section if you are projecting typed values to RDF quads via toQuads and want to control the predicate IRI assigned to each property.

Every property in a registered schema is assigned a predicate IRI at projection time. json-tology provides four layers of control, evaluated in priority order (first match wins):

  1. x-jt-predicate: an explicit IRI annotated directly on the property schema.
  2. Absolute property $id: when the property schema carries a $id that contains ://, that IRI is used as-is.
  3. predicateFor callback: a registry-level function that can return a custom IRI for selected properties.
  4. Default derivation: flat canonical (baseIri/propertyName) or class-scoped (classId#propertyName).

Flat canonical predicates (default)

When enableCanonicalPredicates is true (the default), property predicates are derived as flat shared IRIs from the registry baseIri:

https://bookstore.example/title
https://bookstore.example/isbn
https://bookstore.example/email

The predicate is vocabulary-wide: every class that declares a title property uses the same https://bookstore.example/title predicate. This is the most interoperable form and matches how shared vocabularies like Schema.org assign predicates.

/**
 * Canonical (flat) vs legacy (class-scoped) predicate IRIs.
 *
 * By default (`enableCanonicalPredicates: true`, the default), `toQuads`
 * emits flat predicates derived from the registry `baseIri`:
 *
 *   https://bookstore.example/name
 *   https://bookstore.example/email
 *
 * Passing `enableCanonicalPredicates: false` restores the legacy
 * class-scoped form where each class owns its own predicate namespace:
 *
 *   urn:bookstore:Customer#name
 *   urn:bookstore:Customer#email
 *
 * The canonical form is shared across all classes that carry the same
 * property name — the predicate is vocabulary-wide, not per-class. The
 * legacy form is useful when migrating a codebase that already uses
 * class-scoped IRIs in a triple store or reasoner.
 */

import { JsonTology } from '../../../src/index.js';
import {
  aboxFixtures,
  bookstoreSchemas,
  CustomerSchema
} from '../bookstore/index.js';

// ── Default: canonical flat predicates ────────────────────────────────────
const canonicalJt = JsonTology.create({
  'baseIri': 'https://bookstore.example',
  'enableCanonicalPredicates': true,
  'schemas': bookstoreSchemas
});

// Use a validated instance so the branded types are satisfied.
const customer = canonicalJt.instantiate(CustomerSchema, aboxFixtures.customer);
const canonicalQuads = canonicalJt.toQuads(CustomerSchema, customer);

const canonicalPredicates = canonicalQuads.map((quad) => {
  return quad.predicate.value;
});

// Flat: shared across all classes — every 'name' or 'email' in the vocabulary uses this predicate.
console.assert(
  canonicalPredicates.some((predicate) => {
    return predicate === 'https://bookstore.example/name';
  }),
  'canonical: name predicate is flat https://bookstore.example/name'
);
console.assert(
  canonicalPredicates.some((predicate) => {
    return predicate === 'https://bookstore.example/email';
  }),
  'canonical: email predicate is flat https://bookstore.example/email'
);

console.log('Canonical predicates (sample):');
const canonicalSorted = [...new Set(canonicalPredicates)].sort();

for (const predicate of canonicalSorted) {
  console.log(' ', predicate);
}

// ── Opt-out: legacy class-scoped predicates ───────────────────────────────
const legacyJt = JsonTology.create({
  'baseIri': 'https://bookstore.example',
  'enableCanonicalPredicates': false,
  'schemas': bookstoreSchemas
});

const legacyCustomer = legacyJt.instantiate(CustomerSchema, aboxFixtures.customer);
const legacyQuads = legacyJt.toQuads(CustomerSchema, legacyCustomer);

const legacyPredicates = legacyQuads.map((quad) => {
  return quad.predicate.value;
});

// Class-scoped: the class ID becomes the namespace, property name the local part.
console.assert(
  legacyPredicates.some((predicate) => {
    return predicate.includes('#name');
  }),
  'legacy: name predicate contains #name fragment'
);
console.assert(
  legacyPredicates.some((predicate) => {
    return predicate.includes('#email');
  }),
  'legacy: email predicate contains #email fragment'
);

console.log('\nLegacy predicates (sample):');
const legacySorted = [...new Set(legacyPredicates)].sort();

for (const predicate of legacySorted) {
  console.log(' ', predicate);
}
Output
Press Execute to run this example against the real library.

Class-scoped predicates

Setting enableCanonicalPredicates: false derives a per-class predicate form:

urn:bookstore:Book#title
urn:bookstore:Customer#email
urn:bookstore:Address#city

The class $id becomes the predicate namespace and the property name becomes the local part. This is the right choice for DTO bundles where two structurally-unrelated classes coincidentally share a property name and must keep distinct predicates (e.g. Invoice.name vs Color.name), rather than collapsing onto one shared predicate.

The second half of the example above (example 100) demonstrates the contrast.


predicateFor callback

The predicateFor option on JsonTology.create is a function invoked once per property during ABox projection. Return a string to override the derived IRI; return undefined to fall through to the default.

Declaration.

ts
predicateFor: (ctx: { classId: string; propertyName: string }) => string | undefined

Use this when a consuming vocabulary already mints predicates under a different namespace, for example aligning selected bookstore properties to Schema.org IRIs, and you want the mapping in one place without touching individual schemas.

/**
 * Custom vocabulary IRI via `predicateFor`.
 *
 * The `predicateFor` registry option is a callback invoked once per
 * property during ABox projection. Return a string to override the
 * derived IRI for that property; return `undefined` to fall through to
 * the default flat canonical form.
 *
 * This is useful when a consuming vocabulary already mints predicates
 * under a different namespace — for example, when aligning the bookstore
 * domain to Schema.org property IRIs for selected fields.
 */

import { JsonTology } from '../../../src/index.js';
import {
  aboxFixtures,
  bookstoreSchemas,
  CustomerSchema
} from '../bookstore/index.js';

const jt = JsonTology.create({
  'baseIri': 'https://bookstore.example',
  'predicateFor': ({
    classId,
    propertyName
  }) => {
    // Map Customer name and email to Schema.org equivalents.
    // All other class/property combinations fall through to the default flat predicate.
    const customerMap: Partial<Record<string, string>> = {
      'email': 'https://schema.org/email',
      'name': 'https://schema.org/name'
    };

    return classId === 'urn:bookstore:Customer' ? customerMap[propertyName] : undefined;
  },
  'schemas': bookstoreSchemas
});

const customer = jt.instantiate(CustomerSchema, aboxFixtures.customer);
const quads = jt.toQuads(CustomerSchema, customer);

const predicates = quads.map((quad) => {
  return quad.predicate.value;
});

// Overridden properties use the custom vocabulary IRI.
console.assert(
  predicates.some((predicate) => {
    return predicate === 'https://schema.org/name';
  }),
  'name mapped to https://schema.org/name'
);
console.assert(
  predicates.some((predicate) => {
    return predicate === 'https://schema.org/email';
  }),
  'email mapped to https://schema.org/email'
);

// Properties not covered by predicateFor use the flat canonical form.
console.assert(
  predicates.some((predicate) => {
    return predicate === 'https://bookstore.example/customerId';
  }),
  'customerId falls through to canonical flat https://bookstore.example/customerId'
);

console.log('Predicates emitted for Customer:');
const sorted = [...new Set(predicates)].sort();

for (const predicate of sorted) {
  console.log(' ', predicate);
}
Output
Press Execute to run this example against the real library.

x-jt-predicate

Add x-jt-predicate: '<IRI>' directly to a property schema to pin it to a specific predicate IRI. This takes precedence over predicateFor and the default derivation (only an absolute $id on the property schema ranks higher).

x-jt-predicate is also valid on annotation sub-schemas inside Compose.annotatedEdge — it grounds each annotation triple's predicate to a vocabulary IRI (e.g. https://schema.org/ratingValue). See the annotated-edge predicate grounding section for the pattern.

Use this when a single property must align to an external vocabulary IRI without a registry-level callback.

/**
 * Explicit per-property predicate via `x-jt-predicate`.
 *
 * Adding `x-jt-predicate: '<IRI>'` directly to a property schema pins
 * that property to a specific predicate IRI regardless of the registry
 * `baseIri`, `enableCanonicalPredicates`, or `predicateFor` settings.
 * It is the highest-precedence predicate binding — only the property
 * `$id` (when it is an absolute IRI) takes precedence over it.
 *
 * Use `x-jt-predicate` when a single property must align to an external
 * vocabulary IRI without touching the registry-level `predicateFor`
 * callback.
 */

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

// A minimal schema that pins `isbn` to the Schema.org ISBN predicate.
const BookSchema = {
  '$id': 'https://bookstore.example/Book',
  'properties': {
    'isbn': {
      '$ref': 'https://bookstore.example/Isbn',
      'x-jt-predicate': 'https://schema.org/isbn'
    },
    'title': { '$ref': 'https://bookstore.example/Title' }
  },
  'required': [
    'isbn',
    'title'
  ],
  'type': 'object'
} as const;

const IsbnSchema = {
  '$id': 'https://bookstore.example/Isbn',
  'type': 'string'
} as const;

const TitleSchema = {
  '$id': 'https://bookstore.example/Title',
  'type': 'string'
} as const;

const jt = JsonTology.create({
  'baseIri': 'https://bookstore.example',
  'enableStrictGraph': false,
  'schemas': [
    IsbnSchema,
    TitleSchema,
    BookSchema
  ] as const
});

const quads = jt.toQuads(BookSchema, {
  'isbn': '9783522128001',
  'title': 'Die unendliche Geschichte'
});

const predicates = quads.map((quad) => {
  return quad.predicate.value;
});

// isbn uses the pinned Schema.org predicate — not the flat canonical form.
console.assert(
  predicates.some((predicate) => {
    return predicate === 'https://schema.org/isbn';
  }),
  'isbn emitted as https://schema.org/isbn (x-jt-predicate)'
);

// title uses the default flat canonical form (no x-jt-predicate set).
console.assert(
  predicates.some((predicate) => {
    return predicate === 'https://bookstore.example/title';
  }),
  'title emitted as https://bookstore.example/title (default canonical)'
);

console.log('Predicates emitted:');
for (const predicate of [...new Set(predicates)].sort()) {
  console.log(' ', predicate);
}
Output
Press Execute to run this example against the real library.

Union-domain TBox

When enableCanonicalPredicates: true (the default), each flat predicate IRI appears once in the TBox. Multiple classes that share a property name emit the predicate declaration once, under a rdfs:domain of the union of all owning classes. This is the standard OWL 2 pattern for shared vocabulary predicates.

With enableCanonicalPredicates: false, each class-scoped predicate carries its own independent rdfs:domain declaration (the per-class model).


Priority order summary

PrioritySourceReturns
1x-jt-predicate on the property schemaExplicit IRI string
2Absolute $id on the property schema (includes('://'))Property $id value
3predicateFor(ctx) returning a stringCustom IRI string
4aDefault, canonical flat (enableCanonicalPredicates !== false)baseIri/propertyName
4bDefault, class-scoped (enableCanonicalPredicates: false)classId#propertyName

See also

Released under the MIT License.