diff --git a/src/TermWrapper.ts b/src/TermWrapper.ts index 192274f..b69aee8 100644 --- a/src/TermWrapper.ts +++ b/src/TermWrapper.ts @@ -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 | TermWrapper + * + * if (wrapper.termType === "Literal") { + * wrapper // narrowed to TermWrapper + * } + * ``` + * + * @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 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. @@ -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 } @@ -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 } diff --git a/test/unit/term_wrapper_generic.test.ts b/test/unit/term_wrapper_generic.test.ts new file mode 100644 index 0000000..3ad7019 --- /dev/null +++ b/test/unit/term_wrapper_generic.test.ts @@ -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 : + + :hasString "string 1" . +` + +class NamedNodeChild extends TermWrapper { + 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 | TermWrapper)[] = [ + 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 by the check above + const narrowed: TermWrapper = wrapper + + assert.equal(narrowed.language, "en") + } else { + // Compile-time assertion: narrowed to TermWrapper by the check above + const narrowed: TermWrapper = 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 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") + }) + }) +})