RDF predicates
You only need this section if you are projecting typed values to RDF quads via
toQuadsand 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):
x-jt-predicate: an explicit IRI annotated directly on the property schema.- Absolute property
$id: when the property schema carries a$idthat contains://, that IRI is used as-is. predicateForcallback: a registry-level function that can return a custom IRI for selected properties.- 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/emailThe 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);
}
Class-scoped predicates
Setting enableCanonicalPredicates: false derives a per-class predicate form:
urn:bookstore:Book#title
urn:bookstore:Customer#email
urn:bookstore:Address#cityThe 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.
predicateFor: (ctx: { classId: string; propertyName: string }) => string | undefinedUse 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);
}
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);
}
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
| Priority | Source | Returns |
|---|---|---|
| 1 | x-jt-predicate on the property schema | Explicit IRI string |
| 2 | Absolute $id on the property schema (includes('://')) | Property $id value |
| 3 | predicateFor(ctx) returning a string | Custom IRI string |
| 4a | Default, canonical flat (enableCanonicalPredicates !== false) | baseIri/propertyName |
| 4b | Default, class-scoped (enableCanonicalPredicates: false) | classId#propertyName |
Related
toQuads: ABox quad projectionx-jt-predicatekeyword: per-property explicit predicatex-jt-iriRefkeyword: emit string as NamedNodex-jt-languagekeyword: tag string as rdf:langString
See also
- Bookstore domain: schema definitions used in examples
- Graph concepts: TBox / ABox structure