diff --git a/README.md b/README.md index e51d085..6dbdff1 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ _If you want to know more about how typed-inject works, please read [my blog art - [♻ Lifecycle control](#lifecycle-control) - [🚮 Disposing provided stuff](#disposing-provided-stuff) - [✨ Magic tokens](#magic-tokens) +- [🏷️ Internally registered tokens](#internally-registered-tokens) - [😬 Error handling](#error-handling) - [📖 API reference](#api-reference) - [🤝 Commendation](#commendation) @@ -405,6 +406,53 @@ class Foo { const foo = createInjector().inject(Foo); ``` + + +## 🏷️ Internally registered tokens / Known Classes and Known Factories + +You can register class tokens internally in the classes that need to be provided, and then provide such classes by using the `provideClass` method's overload. To implement the "Known Class", add a `static knownAs` property with a string literal value. This way, it is possible to create a hierarchy of class dependencies without relying on arbitrarily specified tokens, instead opting to consolidate said tokens within the classes themselves. + +```ts +import { createInjector } from 'typed-inject'; + +class Foo { + static knownAs = "FooToken" as const; +} + +class Bar { + static knownAs = "BarToken" as const; + + static inject = [ Foo.knownAs ] as const; + + constructor(foo: Foo) {} +} + +const injector = createInjector() + .provideClass(Foo) + .provideClass(Bar); +``` + +Similarly, it works with `provideFactory` method's overload. + +```ts +import { createInjector } from 'typed-inject'; + +function fooFactory() { + return "foo factory called"; +} +fooFactory.knownAs = 'Foo' as const; + +function barFactory() { + return "bar factory called"; +} +barFactory.inject = [ fooFactory.knownAs ] as const; +barFactory.knownAs = "Bar" as const; + +const injector = createInjector() + .provideFactory(fooFactory) + .provideFactory(barFactory); +``` + ## 😬 Error handling diff --git a/src/InjectorImpl.ts b/src/InjectorImpl.ts index a932d3f..cd61473 100644 --- a/src/InjectorImpl.ts +++ b/src/InjectorImpl.ts @@ -11,6 +11,8 @@ import type { InjectableClass, InjectableFunction, Injectable, + KnownInjectableClass, + KnownInjectableFunction, } from './api/Injectable.js'; import type { Injector } from './api/Injector.js'; import type { Disposable } from './api/Disposable.js'; @@ -105,7 +107,38 @@ abstract class AbstractInjector implements Injector { return provider; } - public provideClass< + provideClass< + Token extends string, + R, + Tokens extends readonly InjectionToken[], + >( + token: Token, + Class: InjectableClass, + scope?: Scope, + ): Injector>; + + provideClass< + KnownAsToken extends string, + R, + Tokens extends readonly InjectionToken[], + >( + Class: KnownInjectableClass, + scope?: Scope, + ): Injector>; + + public provideClass(...args: any[]): any { + if (typeof args[0] === 'string') { + return this._provideClassWithToken( + args[0], + args[1], + args[2] ?? DEFAULT_SCOPE, + ); + } else { + return this._provideClassWithKnownAs(args[0], args[1] ?? DEFAULT_SCOPE); + } + } + + protected _provideClassWithToken< Token extends string, R, Tokens extends InjectionToken[], @@ -119,7 +152,53 @@ abstract class AbstractInjector implements Injector { this.childInjectors.add(provider as Injector); return provider; } - public provideFactory< + + protected _provideClassWithKnownAs< + KnownAsToken extends string, + R, + Tokens extends InjectionToken[], + >( + Class: KnownInjectableClass, + scope = DEFAULT_SCOPE, + ): AbstractInjector> { + this.throwIfDisposed(Class.knownAs); + const provider = new ClassProvider(this, Class.knownAs, scope, Class); + this.childInjectors.add(provider as Injector); + return provider; + } + + provideFactory< + Token extends string, + R, + Tokens extends readonly InjectionToken[], + >( + token: Token, + factory: InjectableFunction, + scope?: Scope, + ): Injector>; + + provideFactory< + KnownAsToken extends string, + R, + Tokens extends readonly InjectionToken[], + >( + factory: KnownInjectableFunction, + scope?: Scope, + ): Injector>; + + public provideFactory(...args: any[]): any { + if (typeof args[0] === 'string') { + return this._provideFactoryWithToken( + args[0], + args[1], + args[2] ?? DEFAULT_SCOPE, + ); + } else { + return this._provideFactoryWithKnownAs(args[0], args[1] ?? DEFAULT_SCOPE); + } + } + + protected _provideFactoryWithToken< Token extends string, R, Tokens extends InjectionToken[], @@ -134,6 +213,20 @@ abstract class AbstractInjector implements Injector { return provider; } + protected _provideFactoryWithKnownAs< + KnownAsToken extends string, + R, + Tokens extends InjectionToken[], + >( + factory: KnownInjectableFunction, + scope = DEFAULT_SCOPE, + ): AbstractInjector> { + this.throwIfDisposed(factory.knownAs); + const provider = new FactoryProvider(this, factory.knownAs, scope, factory); + this.childInjectors.add(provider as Injector); + return provider; + } + public resolve( token: Token, target?: Function, diff --git a/src/api/Injectable.ts b/src/api/Injectable.ts index fb4b324..27755f3 100644 --- a/src/api/Injectable.ts +++ b/src/api/Injectable.ts @@ -7,6 +7,15 @@ export type InjectableClass< Tokens extends readonly InjectionToken[], > = ClassWithInjections | ClassWithoutInjections; +export type KnownInjectableClass< + TContext, + R, + Tokens extends readonly InjectionToken[], + KnownAsToken extends string, +> = InjectableClass & { + readonly knownAs: KnownAsToken; +}; + export interface ClassWithInjections< TContext, R, @@ -26,6 +35,15 @@ export type InjectableFunction< | InjectableFunctionWithInject | InjectableFunctionWithoutInject; +export type KnownInjectableFunction< + TContext, + R, + Tokens extends readonly InjectionToken[], + KnownAsToken extends string, +> = InjectableFunction & { + readonly knownAs: KnownAsToken; +}; + export interface InjectableFunctionWithInject< TContext, R, @@ -41,6 +59,11 @@ export type Injectable< TContext, R, Tokens extends readonly InjectionToken[], -> = - | InjectableClass - | InjectableFunction; + KnownAsToken extends string | undefined = undefined, +> = KnownAsToken extends string + ? + | KnownInjectableClass + | KnownInjectableFunction + : + | InjectableClass + | InjectableFunction; diff --git a/src/api/Injector.ts b/src/api/Injector.ts index 03a7d18..f368506 100644 --- a/src/api/Injector.ts +++ b/src/api/Injector.ts @@ -1,4 +1,9 @@ -import { InjectableClass, InjectableFunction } from './Injectable.js'; +import { + InjectableClass, + InjectableFunction, + KnownInjectableClass, + KnownInjectableFunction, +} from './Injectable.js'; import { InjectionToken } from './InjectionToken.js'; import { Scope } from './Scope.js'; import { TChildContext } from './TChildContext.js'; @@ -47,6 +52,19 @@ export interface Injector { Class: InjectableClass, scope?: Scope, ): Injector>; + /** + * Create a child injector that can provide a value using instances of `Class` for internally registered token `'knownAs'`. The new child injector can resolve all tokens the parent injector can, as well as the new `'knownAs'` token. + * @param Class The class with internally registered token `'knownAs'` to instantiate to provide the value. + * @param scope Decide whether the value must be cached after the factory is invoked once. Use `Scope.Singleton` to enable caching (default), or `Scope.Transient` to disable caching. + */ + provideClass< + KnownAsToken extends string, + R, + Tokens extends readonly InjectionToken[], + >( + Class: KnownInjectableClass, + scope?: Scope, + ): Injector>; /** * Create a child injector that can provide a value using `factory` for token `'token'`. The new child injector can resolve all tokens the parent injector can and the new `'token'`. * @param token The token to associate with the value. @@ -62,6 +80,19 @@ export interface Injector { factory: InjectableFunction, scope?: Scope, ): Injector>; + /** + * Create a child injector that can provide a value using `factory` for internally registered token `'knownAs'`. The new child injector can resolve all tokens the parent injector can and the new `'knownAs'` token. + * @param factory A function, with internally registered token `'knownAs'`, that creates a value using instances of the tokens in `'Tokens'`. + * @param scope Decide whether the value must be cached after the factory is invoked once. Use `Scope.Singleton` to enable caching (default), or `Scope.Transient` to disable caching. + */ + provideFactory< + KnownAsToken extends string, + R, + Tokens extends readonly InjectionToken[], + >( + factory: KnownInjectableFunction, + scope?: Scope, + ): Injector>; /** * Create a child injector that can provide exactly the same as the parent injector. diff --git a/test/unit/Injector.spec.ts b/test/unit/Injector.spec.ts index 7c7407e..f846f8e 100644 --- a/test/unit/Injector.spec.ts +++ b/test/unit/Injector.spec.ts @@ -87,6 +87,33 @@ describe('InjectorImpl', () => { expect(actualFoo.name).eq('foo -> barFactory -> bar -> Foo'); }); + it('should be able to provide a target into a function with knownAs token', () => { + // Arrange + function fooFactory(target: undefined | Function) { + return `foo -> ${target && target.name}`; + } + fooFactory.inject = tokens(TARGET_TOKEN); + fooFactory.knownAs = 'Foo' as const; + function barFactory(target: undefined | Function, fooName: string) { + return `${fooName} -> bar -> ${target && target.name}`; + } + barFactory.inject = tokens(TARGET_TOKEN, fooFactory.knownAs); + barFactory.knownAs = 'Bar' as const; + class Foo { + constructor(public name: string) {} + public static inject = tokens(barFactory.knownAs); + } + + // Act + const actualFoo = rootInjector + .provideFactory(fooFactory) + .provideFactory(barFactory) + .injectClass(Foo); + + // Assert + expect(actualFoo.name).eq('foo -> barFactory -> bar -> Foo'); + }); + it('should be able to provide a target into a class', () => { // Arrange class Foo { @@ -121,6 +148,44 @@ describe('InjectorImpl', () => { expect(actualBaz.bar.foo.target).eq(Bar); }); + it('should be able to provide a target into a class with knownAs token', () => { + // Arrange + class Foo { + constructor(public target: undefined | Function) {} + public static inject = tokens(TARGET_TOKEN); + public static knownAs = 'Foo' as const; + } + + class Bar { + constructor( + public target: undefined | Function, + public foo: Foo, + ) {} + public static inject = tokens(TARGET_TOKEN, Foo.knownAs); + public static knownAs = 'Bar' as const; + } + + class Baz { + constructor( + public bar: Bar, + public target: Function | undefined, + ) {} + public static inject = tokens(Bar.knownAs, TARGET_TOKEN); + public static injectableAs = 'Baz' as const; + } + + // Act + const actualBaz = rootInjector + .provideClass(Foo) + .provideClass(Bar) + .injectClass(Baz); + + // Assert + expect(actualBaz.target).undefined; + expect(actualBaz.bar.target).eq(Baz); + expect(actualBaz.bar.foo.target).eq(Bar); + }); + it('should throw when no provider was found for a class', () => { class FooInjectable { constructor(public foo: string) {} @@ -179,6 +244,32 @@ describe('InjectorImpl', () => { parentInjector.createChildInjector().injectFunction(() => {}), ).not.throw(); }); + + it('should be able to create a child injector with its own scope with knownAs token', async () => { + // Arrange + const parentInjector = rootInjector.provideValue('foo', 42); + let fooDisposed = false; + class Foo implements Disposable { + constructor(public foo: number) {} + public static inject = tokens('foo'); + public static knownAs = 'foo' as const; + public dispose(): void { + fooDisposed = true; + } + } + + // Act + const actualChildInjector = parentInjector.createChildInjector(); + const appInjector = actualChildInjector.provideClass(Foo); + appInjector.resolve('foo'); + + // Assert + await actualChildInjector.dispose(); + expect(fooDisposed).true; + expect(() => + parentInjector.createChildInjector().injectFunction(() => {}), + ).not.throw(); + }); }); describe('ChildInjector', () => { @@ -288,6 +379,22 @@ describe('InjectorImpl', () => { expect(actual.foobar).eq(expectedValue); }); + it('should be able to provide the return value of the factoryMethod with knownAs token', () => { + const expectedValue = { foo: 'bar' }; + function foobar() { + return expectedValue; + } + foobar.knownAs = 'foobar' as const; + + const actual = rootInjector.provideFactory(foobar).injectClass( + class { + constructor(public foobar: { foo: string }) {} + public static inject = tokens('foobar'); + }, + ); + expect(actual.foobar).eq(expectedValue); + }); + it('should be able to provide parent injector values', () => { function answer() { return 42; @@ -306,6 +413,25 @@ describe('InjectorImpl', () => { expect(actual.answer).eq(42); }); + it('should be able to provide parent injector values with knownAs token', () => { + function answer() { + return 42; + } + answer.knownAs = 'answer' as const; + const factoryProvider = rootInjector.provideFactory(answer); + const actual = factoryProvider.injectClass( + class { + constructor( + public injector: Injector<{ answer: number }>, + public answer: number, + ) {} + public static inject = tokens(INJECTOR_TOKEN, answer.knownAs); + }, + ); + expect(actual.injector).eq(factoryProvider); + expect(actual.answer).eq(42); + }); + it('should throw after disposed', async () => { const sut = rootInjector.provideFactory('answer', function answer() { return 42; @@ -337,6 +463,22 @@ describe('InjectorImpl', () => { expect(answerProvider.resolve('answer')).eq(42); }); + it('should be able to decorate an existing token with knownAs token', () => { + function incrementDecorator(n: number) { + return ++n; + } + incrementDecorator.inject = tokens('answer'); + incrementDecorator.knownAs = 'answer' as const; + + const answerProvider = rootInjector + .provideValue('answer', 40) + .provideFactory(incrementDecorator) + .provideFactory(incrementDecorator); + + expect(answerProvider.resolve('answer')).eq(42); + expect(answerProvider.resolve('answer')).eq(42); + }); + it('should be able to change the type of a token', () => { const answerProvider = rootInjector .provideValue('answer', 42) @@ -377,6 +519,24 @@ describe('InjectorImpl', () => { expect(answerProvider.resolve('answer').answer).eq(42); }); + + it('should be able to decorate an existing token with knownAs token', () => { + class Foo { + public static inject = tokens('answer'); + public static knownAs = 'answer' as const; + constructor(innerFoo: { answer: number }) { + this.answer = innerFoo.answer + 1; + } + public answer: number; + } + + const answerProvider = rootInjector + .provideValue('answer', { answer: 40 }) + .provideClass(Foo) + .provideClass(Foo); + + expect(answerProvider.resolve('answer').answer).eq(42); + }); }); describe('dispose', () => { @@ -386,19 +546,37 @@ describe('InjectorImpl', () => { public dispose2 = sinon.stub(); public dispose = sinon.stub(); } + class KnownFoo { + public dispose21 = sinon.stub(); + public dispose = sinon.stub(); + public static knownAs = 'known-foo' as const; + } function barFactory(): Disposable & { dispose3(): void } { return { dispose: sinon.stub(), dispose3: sinon.stub() }; } + function knownBarFactory(): Disposable & { dispose31(): void } { + return { dispose: sinon.stub(), dispose31: sinon.stub() }; + } + knownBarFactory.knownAs = 'known-bar' as const; class Baz { constructor( public readonly bar: Disposable & { dispose3(): void }, + public readonly knownBar: Disposable & { dispose31(): void }, public readonly foo: Foo, + public readonly knownFoo: KnownFoo, ) {} - public static inject = tokens('bar', 'foo'); + public static inject = tokens( + 'bar', + knownBarFactory.knownAs, + 'foo', + KnownFoo.knownAs, + ); } const baz = rootInjector .provideClass('foo', Foo) + .provideClass(KnownFoo) .provideFactory('bar', barFactory) + .provideFactory(knownBarFactory) .injectClass(Baz); // Act @@ -407,27 +585,48 @@ describe('InjectorImpl', () => { // Assert expect(baz.bar.dispose).called; expect(baz.foo.dispose).called; + expect(baz.knownBar.dispose).called; + expect(baz.knownFoo.dispose).called; expect(baz.foo.dispose2).not.called; expect(baz.bar.dispose3).not.called; + expect(baz.knownFoo.dispose21).not.called; + expect(baz.knownBar.dispose31).not.called; }); it('should also dispose transient dependencies', async () => { class Foo { public dispose = sinon.stub(); } + class KnownFoo { + public dispose = sinon.stub(); + public static knownAs = 'known-foo' as const; + } function barFactory(): Disposable { return { dispose: sinon.stub() }; } + function knownBarFactory(): Disposable { + return { dispose: sinon.stub() }; + } + knownBarFactory.knownAs = 'known-bar' as const; class Baz { constructor( public readonly bar: Disposable, + public readonly knownBar: Disposable, public readonly foo: Foo, + public readonly knownFoo: KnownFoo, ) {} - public static inject = tokens('bar', 'foo'); + public static inject = tokens( + 'bar', + knownBarFactory.knownAs, + 'foo', + KnownFoo.knownAs, + ); } const baz = rootInjector .provideClass('foo', Foo, Scope.Transient) + .provideClass(KnownFoo, Scope.Transient) .provideFactory('bar', barFactory, Scope.Transient) + .provideFactory(knownBarFactory, Scope.Transient) .injectClass(Baz); // Act @@ -469,6 +668,40 @@ describe('InjectorImpl', () => { expect(child.dispose).calledBefore(child.parent.dispose); }); + it('should dispose dependencies in correct order with knownAs token (child first)', async () => { + class Grandparent { + public dispose = sinon.stub(); + public static knownAs = 'Grandparent' as const; + } + class Parent { + public dispose = sinon.stub(); + public static knownAs = 'Parent' as const; + } + class Child { + constructor( + public readonly parent: Parent, + public readonly grandparent: Grandparent, + ) {} + public static inject = tokens(Parent.knownAs, Grandparent.knownAs); + public static knownAs = 'Child' as const; + public dispose = sinon.stub(); + } + const bazProvider = rootInjector + .provideClass(Grandparent, Scope.Transient) + .provideClass(Parent) + .provideClass(Child); + const child = bazProvider.resolve('Child'); + const newGrandparent = bazProvider.resolve('Grandparent'); + + // Act + await rootInjector.dispose(); + + // Assert + expect(child.parent.dispose).calledBefore(child.grandparent.dispose); + expect(child.parent.dispose).calledBefore(newGrandparent.dispose); + expect(child.dispose).calledBefore(child.parent.dispose); + }); + it('should not dispose injected classes or functions', async () => { class Foo { public dispose = sinon.stub(); @@ -521,6 +754,35 @@ describe('InjectorImpl', () => { expect(baz.foo.dispose).eq(true); }); + it('should not break on non-disposable dependencies with knownAs token', async () => { + class Foo { + public dispose = true; + public static knownAs = 'Foo' as const; + } + function barFactory(): { dispose: string } { + return { dispose: 'no-fn' }; + } + barFactory.knownAs = 'Bar' as const; + class Baz { + constructor( + public readonly bar: { dispose: string }, + public readonly foo: Foo, + ) {} + public static inject = tokens(barFactory.knownAs, Foo.knownAs); + } + const bazInjector = rootInjector + .provideClass(Foo) + .provideFactory(barFactory); + const baz = bazInjector.injectClass(Baz); + + // Act + await bazInjector.dispose(); + + // Assert + expect(baz.bar.dispose).eq('no-fn'); + expect(baz.foo.dispose).eq(true); + }); + it('should not dispose dependencies twice', async () => { const fooProvider = rootInjector.provideClass( 'foo', @@ -534,6 +796,19 @@ describe('InjectorImpl', () => { expect(foo.dispose).calledOnce; }); + it('should not dispose dependencies twice with knownAs token', async () => { + class Foo implements Disposable { + public dispose = sinon.stub(); + public static knownAs = 'Foo' as const; + } + + const fooProvider = rootInjector.provideClass(Foo); + const foo = fooProvider.resolve('Foo'); + await fooProvider.dispose(); + await fooProvider.dispose(); + expect(foo.dispose).calledOnce; + }); + it('should await dispose()', async () => { // Arrange const fooStub = sinon.stub(); @@ -617,6 +892,39 @@ describe('InjectorImpl', () => { expect(child.dispose).called; expect(child.parent.dispose).not.called; }); + + it("should not dispose it's parent provider with knownAs token", async () => { + // Arrange + class Grandparent { + public dispose = sinon.stub(); + public static knownAs = 'Grandparent' as const; + } + class Parent { + public dispose = sinon.stub(); + public static knownAs = 'Parent' as const; + } + class Child { + constructor( + public readonly parent: Parent, + public readonly grandparent: Grandparent, + ) {} + public static inject = tokens(Parent.knownAs, Grandparent.knownAs); + public static knownAs = 'Child' as const; + public dispose = sinon.stub(); + } + const parentProvider = rootInjector + .provideClass(Grandparent, Scope.Transient) + .provideClass(Parent); + const childProvider = parentProvider.provideClass(Child); + const child = childProvider.resolve('Child'); + + // Act + await childProvider.dispose(); + + // Assert + expect(child.dispose).called; + expect(child.parent.dispose).not.called; + }); }); describe('dependency tree', () => { @@ -672,6 +980,60 @@ describe('InjectorImpl', () => { expect(actual.log).eq(expectedLogger); }); + it('should be able to inject a dependency tree with classes with knownAs token', () => { + // Arrange + class Logger { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public info(_msg: string) {} + } + class GrandChild { + public baz = 'qux'; + constructor(public log: Logger) {} + public static inject = tokens('logger'); + public static knownAs = 'GrandChild' as const; + } + class Child1 { + public bar = 'foo'; + constructor( + public log: Logger, + public grandchild: GrandChild, + ) {} + public static inject = tokens('logger', GrandChild.knownAs); + public static knownAs = 'Child1' as const; + } + class Child2 { + public foo = 'bar'; + constructor(public log: Logger) {} + public static inject = tokens('logger'); + } + class Parent { + constructor( + public readonly child: Child1, + public readonly child2: Child2, + public readonly log: Logger, + ) {} + public static inject = tokens(Child1.knownAs, 'child2', 'logger'); + } + const expectedLogger = new Logger(); + + // Act + const actual = rootInjector + .provideValue('logger', expectedLogger) + .provideClass(GrandChild) + .provideClass(Child1) + .provideClass('child2', Child2) + .injectClass(Parent); + + // Assert + expect(actual.child.bar).eq('foo'); + expect(actual.child2.foo).eq('bar'); + expect(actual.child.log).eq(expectedLogger); + expect(actual.child2.log).eq(expectedLogger); + expect(actual.child.grandchild.log).eq(expectedLogger); + expect(actual.child.grandchild.baz).eq('qux'); + expect(actual.log).eq(expectedLogger); + }); + it('should throw an Injection error with correct message when injection failed with a runtime error', () => { // Arrange const expectedCause = Error('Expected error'); @@ -707,5 +1069,44 @@ describe('InjectorImpl', () => { path: [Parent, 'child', Child, 'grandChild', GrandChild], }); }); + + it('should throw an Injection error with correct message when injection failed with knownAs token with a runtime error', () => { + // Arrange + const expectedCause = Error('Expected error'); + class GrandChild { + public baz = 'baz'; + public static knownAs = 'GrandChildToken' as const; + constructor() { + throw expectedCause; + } + } + class Child { + public bar = 'foo'; + constructor(public grandchild: GrandChild) {} + public static inject = tokens(GrandChild.knownAs); + public static knownAs = 'ChildToken' as const; + } + class Parent { + constructor(public readonly child: Child) {} + public static inject = tokens(Child.knownAs); + public static knownAs = 'ParentToken' as const; + } + + // Act + const act = () => + rootInjector + .provideClass(GrandChild) + .provideClass(Child) + .injectClass(Parent); + + // Assert + expect(act) + .throws(InjectionError) + .which.deep.includes({ + message: + 'Could not inject [class Parent] -> [token "ChildToken"] -> [class Child] -> [token "GrandChildToken"] -> [class GrandChild]. Cause: Expected error', + path: [Parent, 'ChildToken', Child, 'GrandChildToken', GrandChild], + }); + }); }); });