Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 30 additions & 6 deletions src/TermWrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,15 +68,36 @@ import type { IRdfJsTerm } from "./type/IRdfJsTerm.js"
* // Our instance used as subject when matching statements in a dataset
* dataset.match(instance as Term)
* ```
*
* @example Narrowing the wrapped term type
* The type parameter `T` captures the type of the wrapped term, so {@link TermWrapper.termType | termType} narrows accordingly and the compiler can discriminate wrappers by the kind of term they wrap:
* ```ts
* const namedNode = new TermWrapper(factory.namedNode("http://example.com/someSubject"), dataset, factory)
* namedNode.termType // typed as "NamedNode"
*
* const literal = new TermWrapper(factory.literal("some value"), dataset, factory)
* literal.termType // typed as "Literal"
*
* let wrapper: TermWrapper<NamedNode> | TermWrapper<Literal>
*
* if (wrapper.termType === "Literal") {
* wrapper // narrowed to TermWrapper<Literal>
* }
* ```
*
* @template T - The type of the {@link Term} being wrapped. Defaults to {@link Term}, so existing declarations like `class SomeClass extends TermWrapper` are unaffected. Inferred from the term passed to the {@link TermWrapper.constructor | constructor}; remains the default when constructing from an IRI string.
*/
export class TermWrapper implements IRdfJsTerm {
private readonly original: Term
export class TermWrapper<T extends Term = Term> implements IRdfJsTerm {
private readonly original: T
private readonly _dataset: DatasetCore
private readonly _factory: DataFactory

/**
* Creates a new instance of {@link TermWrapper}.
*
* @remarks
* The IRI is wrapped as a {@link NamedNode} created with the `factory`, so the type parameter `T` is not inferred from this overload and remains the default {@link Term} unless given explicitly.
*
* @param term The IRI of a named node that is the original term being wrapped.
* @param dataset The dataset that contains the term being wrapped.
* @param factory A collection of methods for creating terms.
Expand All @@ -86,14 +107,17 @@ export class TermWrapper implements IRdfJsTerm {
/**
* Creates a new instance of {@link TermWrapper}.
*
* @remarks
* The type parameter `T` is inferred from the `term` argument, narrowing {@link TermWrapper.termType | termType} to the term type of the wrapped term.
*
* @param term The original term being wrapped.
* @param dataset The dataset that contains the term being wrapped.
* @param factory A collection of methods for creating terms.
*/
constructor(term: Term, dataset: DatasetCore, factory: DataFactory)
constructor(term: T, dataset: DatasetCore, factory: DataFactory)

constructor(term: string | Term, dataset: DatasetCore, factory: DataFactory) {
this.original = typeof term === "string" ? factory.namedNode(term) : term
constructor(term: string | T, dataset: DatasetCore, factory: DataFactory) {
this.original = (typeof term === "string" ? factory.namedNode(term) : term) as T
this._dataset = dataset
this._factory = factory
}
Expand Down Expand Up @@ -189,7 +213,7 @@ export class TermWrapper implements IRdfJsTerm {

//#region Implementation of RDF/JS Term

get termType(): Term["termType"] {
get termType(): T["termType"] {
return this.original.termType
}

Expand Down
121 changes: 121 additions & 0 deletions test/unit/term_wrapper_generic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import assert from "node:assert"
import { describe, it } from "node:test"
import { DataFactory } from "n3"
import { LiteralAs, RequiredFrom, TermWrapper } from "@rdfjs/wrapper"
import { Child } from "./model/Child.js"
import { datasetFromRdf } from "./util/datasetFromRdf.js"
import { Example } from "./vocabulary/Example.js"
import type { Literal, NamedNode, Term } from "@rdfjs/types"

const rdf = `
prefix : <https://example.org/>

<x> :hasString "string 1" .
`

class NamedNodeChild extends TermWrapper<NamedNode> {
public get hasString(): string {
return RequiredFrom.subjectPredicate(this, Example.hasString, LiteralAs.string)
}
}

await describe("Term Wrapper generic", async () => {
const dataset = datasetFromRdf(rdf)

await describe("Default type parameter", async () => {
await it("wraps an IRI string as before", async () => {
const wrapper = new TermWrapper("x", dataset, DataFactory)

// Compile-time assertion: without inference the term type stays the full union
const termType: Term["termType"] = wrapper.termType

assert.equal(termType, "NamedNode")
assert.equal(wrapper.value, "x")
})

await it("leaves subclasses that do not pass a type argument unchanged", async () => {
const child = new Child(DataFactory.blankNode("b1"), dataset, DataFactory)

assert.equal(child.termType, "BlankNode")
assert.equal(child.value, "b1")
})
})

await describe("Inference from the constructor", async () => {
await it("narrows the term type of a wrapped named node", async () => {
const wrapper = new TermWrapper(DataFactory.namedNode("x"), dataset, DataFactory)

// Compile-time assertion: termType is narrowed to the literal type "NamedNode"
const termType: "NamedNode" = wrapper.termType

assert.equal(termType, "NamedNode")
})

await it("narrows the term type of a wrapped literal", async () => {
const wrapper = new TermWrapper(DataFactory.literal("some value", "en"), dataset, DataFactory)

// Compile-time assertion: termType is narrowed to the literal type "Literal"
const termType: "Literal" = wrapper.termType

// @ts-expect-error the compiler knows a wrapped literal is never a named node
wrapper.termType === "NamedNode"

assert.equal(termType, "Literal")
})

await it("keeps term-type-specific getters available", async () => {
const wrapper = new TermWrapper(DataFactory.literal("some value", "en"), dataset, DataFactory)

assert.equal(wrapper.language, "en")
})
})

await describe("Narrowing wrapper unions", async () => {
await it("discriminates wrappers by the term type they wrap", async () => {
const wrappers: (TermWrapper<NamedNode> | TermWrapper<Literal>)[] = [
new TermWrapper(DataFactory.namedNode("x"), dataset, DataFactory),
new TermWrapper(DataFactory.literal("some value", "en"), dataset, DataFactory),
]

for (const wrapper of wrappers) {
if (wrapper.termType === "Literal") {
// Compile-time assertion: narrowed to TermWrapper<Literal> by the check above
const narrowed: TermWrapper<Literal> = wrapper

assert.equal(narrowed.language, "en")
} else {
// Compile-time assertion: narrowed to TermWrapper<NamedNode> by the check above
const narrowed: TermWrapper<NamedNode> = wrapper

assert.equal(narrowed.value, "x")
}
}
})

await it("remains assignable to the default wrapper type", async () => {
const narrowed = new TermWrapper(DataFactory.namedNode("x"), dataset, DataFactory)

// Compile-time assertion: TermWrapper<NamedNode> is assignable to TermWrapper without casts
const general: TermWrapper = narrowed

assert.equal(general.termType, "NamedNode")
})
})

await describe("Subclasses with an explicit type argument", async () => {
await it("narrows the term type of instances", async () => {
const child = new NamedNodeChild(DataFactory.namedNode("x"), dataset, DataFactory)

// Compile-time assertion: termType is narrowed to the literal type "NamedNode"
const termType: "NamedNode" = child.termType

assert.equal(termType, "NamedNode")
})

await it("works with the mapping helpers", async () => {
const child = new NamedNodeChild(DataFactory.namedNode("x"), dataset, DataFactory)

assert.equal(child.hasString, "string 1")
})
})
})