Skolemization
Skolemization is the process of replacing blank nodes in an RDF graph with deterministic IRIs so consumers can refer to those nodes stably across calls and stores. The W3C term comes from
Skolem(1920)'s function: every existential variable becomes a fresh constant. In RDF, every anonymous node becomes a fresh IRI.
json-tology projects every typed value into ABox quads via toQuads. By default, every emitted object gets a deterministic IRI of the form <baseIri>/instances/<classId>-<contentHash>. Sometimes that's exactly right - content-addressed IRIs are stable, deduplicating, and need no external coordination. Sometimes you want something else: a property-derived IRI, a UUID, an explicit override, or genuine blank nodes. The iriFor option (and the Skolemize helper class) gives you that control.
Relevant standards:
The four built-in strategies
Skolemize is a static-only helper class exposing four reusable minting strategies. Each returns a SkolemizeFnType: a function (ctx) => string | undefined suitable for the iriFor option on toQuads.
Skolemize.hash({ baseIri })
Hashes the value with FNV-1a and emits <baseIri>/instances/<contentHash>. Deterministic: equal values produce equal IRIs across calls and processes.
This is not identical to the projection's built-in default IRI minter. The default minter (defaultInstanceIri) additionally prefixes the escaped class identifier, emitting <baseIri>/instances/<classId>-<contentHash>. Skolemize.hash omits the classId segment, so its output differs from the projection default even for identical input values.
/**
* Skolemize.hash — default-equivalent content-addressed minter.
*
* Hashes the value with FNV-1a and emits `<baseIri>/instances/<hash>`.
* Deterministic — equal values produce equal IRIs across calls and processes.
*/
import { Skolemize } from '../../../src/index.js';
import {
aboxFixtures, bookstoreEntities, CustomerSchema
} from '../bookstore/index.js';
const quads = bookstoreEntities.toQuads(CustomerSchema, aboxFixtures.customer, { 'iriFor': Skolemize.hash({ 'baseIri': 'https://shop.example.com' }) });
console.assert(quads.length > 0, 'hash-minted IRIs emit quads');
console.assert(
quads.some((quad) => {
return quad.subject.value.startsWith('https://shop.example.com/instances/');
}),
'hash-minted root carries the configured baseIri prefix'
);
const rootIri = quads[0]?.subject.value ?? '';
console.log('hash-minted IRI:', rootIri);
Skolemize.wellKnownGenid(baseIri)
Mints IRIs matching the RDF 1.1 §3.5 well-known genid pattern: <baseIri>/.well-known/genid/<hash>. These IRIs are intentionally reversible - fromQuads({ deskolemize: true }) recognizes the pattern and rewrites the IRIs back to blank nodes during lift.
/**
* Skolemize.wellKnownGenid — reversible W3C RDF 1.1 §3.5 pattern.
*
* Mints IRIs of the form `<baseIri>/.well-known/genid/<hash>`. These are
* intentionally reversible — fromQuads({ deskolemize: true }) recognises
* the pattern and rewrites the IRIs back to blank nodes during lift.
*
* Use this when publishing RDF over the wire (which requires named
* subjects) while preserving blank-node identity on the receiving end.
*/
import { Skolemize } from '../../../src/index.js';
import {
aboxFixtures, bookstoreEntities, CustomerSchema
} from '../bookstore/index.js';
const customer = bookstoreEntities.instantiate(CustomerSchema, aboxFixtures.customer);
const quads = bookstoreEntities.toQuads(CustomerSchema, customer, { 'iriFor': Skolemize.wellKnownGenid('https://shop.example.com') });
// Round-trip back to blank-node semantics — use the string key form for full type inference
const restoredList = bookstoreEntities.fromQuads(CustomerSchema.$id, quads, { 'deskolemize': true });
const restored = restoredList[0];
if (restored === undefined) {
throw new Error('expected restored customer');
}
console.assert(restored.customerId === customer.customerId, 'customer id round-tripped through genid');
const genidIri = quads[0]?.subject.value ?? '';
console.log('well-known genid IRI:', genidIri);
console.log('round-tripped customerId:', restored.customerId);
Skolemize.isWellKnownGenid(iri) tests whether a given IRI matches the well-known genid pattern. Useful when writing custom deskolemization passes or filtering quad sets:
/**
* Skolemize.wellKnownGenid and Skolemize.isWellKnownGenid.
*
* `Skolemize.wellKnownGenid(baseIri)` returns a strategy that mints IRIs of
* the form `<baseIri>/.well-known/genid/<contentHash>`. These are reversible:
* `fromQuads({ deskolemize: true })` detects the pattern and rewrites such
* IRIs back to blank nodes on lift.
*
* `Skolemize.isWellKnownGenid(iri)` tests whether a given IRI matches the
* W3C RDF 1.1 §3.5 well-known genid pattern — useful when writing custom
* deskolemization passes or filtering quad sets.
*/
import {
aboxFixtures, bookstoreEntities, CustomerSchema
} from '../bookstore/index.js';
import { Skolemize } from '../../../src/index.js';
// Produce a well-known genid IRI by minting quads with the wellKnownGenid strategy.
const genidStrategy = Skolemize.wellKnownGenid('https://shop.example.com');
const customer = bookstoreEntities.instantiate(CustomerSchema, aboxFixtures.customer);
const quads = bookstoreEntities.toQuads(CustomerSchema, customer, { 'iriFor': genidStrategy });
const subjectIri = quads[0]?.subject.value ?? '';
console.assert(subjectIri.length > 0, 'toQuads must produce at least one quad with a subject');
// The minted IRI must contain the .well-known/genid/ path segment.
console.assert(
subjectIri.includes('/.well-known/genid/'),
`subject IRI must contain /.well-known/genid/; got: ${subjectIri}`
);
console.log('minted well-known genid IRI:', subjectIri);
// isWellKnownGenid — true for a well-known genid IRI, false for plain IRIs.
const isGenid = Skolemize.isWellKnownGenid(subjectIri);
const isGenidPlain = Skolemize.isWellKnownGenid('https://shop.example.com/customers/alice');
const isGenidEmpty = Skolemize.isWellKnownGenid('');
console.assert(isGenid, 'isWellKnownGenid must return true for a minted genid IRI');
console.assert(
!isGenidPlain,
'isWellKnownGenid must return false for a plain IRI'
);
console.assert(
!isGenidEmpty,
'isWellKnownGenid must return false for an empty string'
);
console.log('isWellKnownGenid(genid IRI):', isGenid);
console.log('isWellKnownGenid(plain IRI):', isGenidPlain);
console.log('isWellKnownGenid(\'\'):', isGenidEmpty);
// Round-trip: deskolemize recovers the original typed object from genid quads.
const restoredList = bookstoreEntities.fromQuads(CustomerSchema.$id, quads, { 'deskolemize': true });
const restored = restoredList[0];
if (restored === undefined) {
throw new Error('expected restored customer');
}
console.assert(
restored.customerId === customer.customerId,
'deskolemized customer must match original customerId'
);
console.log('round-tripped customerId:', restored.customerId);
Use this strategy when you want to publish RDF over the wire (which requires named subjects) but preserve blank-node identity on the receiving end.
Skolemize.uuid()
Mints urn:uuid:<v4>. Non-deterministic - every emission gets a fresh identity. Useful when you want unique IRIs and don't care about content addressing or external joins.
/**
* Skolemize.uuid — non-deterministic urn:uuid minter.
*
* Mints `urn:uuid:<v4>` for every emission. Useful when you want unique
* IRIs and don't care about content addressing or external joins.
*
* The Review schema stands in for a generic "event-like" object here:
* each call produces a fresh identity even when the payload is identical.
*/
import { Skolemize } from '../../../src/index.js';
import {
aboxFixtures, bookstoreEntities, ReviewSchema
} from '../bookstore/index.js';
const firstPass = bookstoreEntities.toQuads(ReviewSchema, aboxFixtures.review, { 'iriFor': Skolemize.uuid() });
const secondPass = bookstoreEntities.toQuads(ReviewSchema, aboxFixtures.review, { 'iriFor': Skolemize.uuid() });
const firstSubject = firstPass[0]?.subject.value ?? '';
const secondSubject = secondPass[0]?.subject.value ?? '';
console.assert(firstSubject.startsWith('urn:uuid:'), 'first emission uses urn:uuid');
console.assert(secondSubject.startsWith('urn:uuid:'), 'second emission uses urn:uuid');
console.assert(firstSubject !== secondSubject, 'each call mints a fresh UUID');
console.log('first UUID IRI:', firstSubject);
console.log('second UUID IRI:', secondSubject);
Skolemize.fromProperty(name, { baseIri, fallback })
Mints <baseIri>/<value[name]> when the value has a non-empty string at value[name]. Otherwise delegates to fallback (defaults to Skolemize.hash).
/**
* Skolemize.fromProperty — mint subject IRIs from a value property.
*
* Reads `value[name]` when it is a non-empty string and emits
* `<baseIri>/<value[name]>`. Otherwise delegates to the fallback
* (defaults to Skolemize.hash), so heterogeneous instance trees still
* produce IRIs for every object.
*
* Bastian Balthazar Bux's customer record has a `customerId` field, so the
* minter resolves the root subject from that property directly.
*/
import { Skolemize } from '../../../src/index.js';
import {
aboxFixtures, bookstoreEntities, CustomerSchema
} from '../bookstore/index.js';
const quads = bookstoreEntities.toQuads(CustomerSchema, aboxFixtures.customer, { 'iriFor': Skolemize.fromProperty('customerId', { 'baseIri': 'https://shop.example.com/customers/by-id' }) });
const rootIri = quads[0]?.subject.value ?? '';
console.assert(
rootIri === `https://shop.example.com/customers/by-id/${aboxFixtures.customer.customerId}`,
`root IRI minted from customerId property: ${rootIri}`
);
console.log('fromProperty root IRI:', rootIri);
The fallback runs whenever the property is missing or not a non-empty string, so heterogeneous instance trees still produce IRIs for every object.
Skolemize.compose(...strategies)
Tries each strategy in order; the first non-undefined return wins. Use this to build per-class minting policies:
/**
* Skolemize.compose — chain strategies; first non-undefined wins.
*
* Useful for per-class minting policies that fall back through a list
* of preferences before defaulting to the hash strategy.
*
* aboxFixtures.customer has a `customerId` field, so the first composed
* fromProperty('customerId') strategy wins. If the value lacks `customerId`, compose
* advances to the next strategy in the chain.
*/
import { Skolemize } from '../../../src/index.js';
import {
aboxFixtures, bookstoreEntities, CustomerSchema
} from '../bookstore/index.js';
const strategy = Skolemize.compose(
Skolemize.fromProperty('customerId', { 'baseIri': 'https://shop.example.com/by-id' }),
Skolemize.fromProperty('email', { 'baseIri': 'https://shop.example.com/by-email' }),
Skolemize.hash({ 'baseIri': 'https://shop.example.com' })
);
const quads = bookstoreEntities.toQuads(CustomerSchema, aboxFixtures.customer, { 'iriFor': strategy });
const rootIri = quads[0]?.subject.value ?? '';
console.assert(
rootIri === `https://shop.example.com/by-id/${aboxFixtures.customer.customerId}`,
`composed strategy resolved through customerId property: ${rootIri}`
);
console.log('composed strategy root IRI:', rootIri);
Custom strategies
iriFor accepts any function with the SkolemizeFnType signature:
/**
* SkolemizeFnType — the function signature accepted by iriFor.
*
* Custom strategies receive the JSON Pointer path, the current value,
* and the depth (0 at the root, +1 per nested object). Returning
* undefined falls through to the default Skolemize.hash minter.
*
* Within a single projection call, results are memoized by value
* reference — the same input object always produces the same IRI
* within one emission.
*/
import type { SkolemizeFnType } from '../../../src/types/SkolemizeFnType.js';
// ctx.path is a JSON-Pointer-style path to the current value.
// ctx.value is the object being projected.
// ctx.depth is 0 at the root and +1 per nested object.
const strategy: SkolemizeFnType = (ctx) => {
return ctx.depth === 0
? 'https://shop.example.com/root'
: undefined;
};
console.assert(typeof strategy === 'function', 'SkolemizeFnType is a function shape');
console.assert(
strategy({
'depth': 0,
'path': '',
'value': {}
}) === 'https://shop.example.com/root',
'root receives the fixed IRI'
);
console.assert(
strategy({
'depth': 1,
'path': '/nested',
'value': {}
}) === undefined,
'nested falls through to default'
);
console.log('root IRI from strategy:', strategy({
'depth': 0,
'path': '',
'value': {}
}));
console.log('nested returns:', strategy({
'depth': 1,
'path': '/nested',
'value': {}
}));
Returning undefined falls through to the default Skolemize.hash minter. Within a single projectAbox call, results are memoized by value reference: the same input object always produces the same IRI within one emission.
/**
* Custom iriFor function — derive the root subject from a domain id.
*
* Returning undefined from a custom strategy falls through to the
* default Skolemize.hash minter, so nested objects still receive
* deterministic IRIs without explicit handling.
*/
import {
aboxFixtures, bookstoreEntities, OrderSchema
} from '../bookstore/index.js';
const order = bookstoreEntities.instantiate(OrderSchema, aboxFixtures.order);
const quads = bookstoreEntities.toQuads(OrderSchema, order, {
// Mint at depth 0 from the value's `id` field; nested objects and
// non-record values fall through to the default hash minter.
'iriFor': (ctx) => {
const isRootRecord = ctx.depth === 0 && typeof ctx.value === 'object' && ctx.value !== null;
const id = isRootRecord ? (ctx.value as { 'orderId'?: string }).orderId : undefined;
return typeof id === 'string'
? `https://shop.example.com/orders/${id}`
: undefined;
}
});
const rootIri = quads[0]?.subject.value ?? '';
console.assert(
rootIri === `https://shop.example.com/orders/${aboxFixtures.order.orderId}`,
`custom function minted root from id: ${rootIri}`
);
console.log('custom function root IRI:', rootIri);
Two shorthand strings
For the most common cases, iriFor accepts a string literal:
- A regular IRI:
iriFor: 'https://shop.example.com/orders/A-1234': applied at the root only (depth 0). Nested objects fall through to the default minter. - The literal
'blank-node': emits_:b<n>blank nodes for every projected object. The counter is scoped to oneprojectAboxcall, so two calls in a row both start at_:b0.
Registry-level defaults
JsonTology.create accepts the same options as call sites, applied as defaults that per-call options override:
/**
* Registry-level defaults vs per-call overrides for iriFor / graphIri.
*
* JsonTology.create accepts the same iriFor / defaultGraphIri options
* as the toQuads call site. Per-call overrides win when both are set.
*
* The bookstoreEntities registry is created without those defaults, so
* here we exercise the override path explicitly. The shape of the
* options object is identical at the create() and toQuads() boundaries.
*/
import { Skolemize } from '../../../src/index.js';
import type { JsonTologyOptionsType } from '../../../src/types/JsonTologyOptionsType.js';
import {
aboxFixtures, bookstoreEntities, OrderSchema
} from '../bookstore/index.js';
// Type-level proof: the same options shape applies at create() time.
const registryDefaults: Pick<
JsonTologyOptionsType,
'defaultDeskolemize' | 'defaultGraphIri' | 'iriFor'
> = {
'defaultDeskolemize': true,
'defaultGraphIri': 'https://shop.example.com/graphs/main',
'iriFor': Skolemize.wellKnownGenid('https://shop.example.com')
};
void registryDefaults;
const order = bookstoreEntities.instantiate(OrderSchema, aboxFixtures.order);
// Per-call override wins over any registry default. Here we use the
// existing registry (no defaults set) and override at the call site.
const named = bookstoreEntities.toQuads(OrderSchema, order, { 'iriFor': 'https://shop.example.com/orders/A-1234' });
console.assert(named.length > 0, 'per-call iriFor override emitted quads');
console.assert(
named.some((quad) => {
return quad.subject.value === 'https://shop.example.com/orders/A-1234';
}),
'root subject reflects the per-call override'
);
console.log('per-call override root IRI:', named[0]?.subject.value);
The 'blank-node' registry-level default is re-instantiated on every call so the per-call counter starts fresh.
Choosing a strategy
| Situation | Strategy |
|---|---|
| Content-addressed identity (deterministic, dedup-safe, no classId prefix) | Skolemize.hash |
| Domain identifier on the value | Skolemize.fromProperty |
| Wire transport with blank-node round-trip | Skolemize.wellKnownGenid + fromQuads({ deskolemize: true }) |
| Fresh anonymous identity, every time | Skolemize.uuid |
| Pure RDF blank nodes (no IRI at all) | iriFor: 'blank-node' |
| Complex per-class rules | Skolemize.compose or a custom function |
| Single fixed root IRI override | iriFor: 'https://...' |
Related
- RDF round-trip with
toQuads/fromQuads- the projection API - Graph concepts - canonical graph structure
- Static helpers - graph emission options - registry-level options