diff --git a/README.md b/README.md index 79505d3..0f1a160 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,43 @@ In the example above, a child injector is created. It can provide values for the The `rootInjector` always remains stateless. So don't worry about reusing it in your tests or reusing it for different parts of your application. However, any ChildInjector _is stateful_. For example, it can [cache the injected value](#-control-lifecycle) or [keep track of stuff to dispose](#-disposing-provided-stuff) +## 🎄 Decorate your dependencies + +A common use case for dependency injection is the [decorator design pattern](https://en.wikipedia.org/wiki/Decorator_pattern). It is used to dynamically add functionality to existing dependencies. Typed inject supports decoration of existing dependencies using its `provideFactory` and `provideClass` methods. + +```ts +import { tokens, rootInjector } from 'typed-inject'; + +class Foo { + public bar() { + console.log ('bar!'); + } +} + +function fooDecorator(foo: Foo) { + return { + bar() { + console.log('before call'); + foo.bar(); + console.log('after call'); + } + }; +} +fooDecorator.inject = tokens('foo'); + +const fooProvider = rootInjector + .provideClass('foo', Foo) + .provideFactory('foo', fooDecorator); +const foo = fooProvider.resolve('foo'); + +foo.bar(); +// => "before call" +// => "bar!" +// => "after call" +``` + +In this example above the `Foo` class is decorated by the `fooDecorator`. + ## ♻ Control lifecycle You can determine the lifecycle of dependencies with the third `Scope` parameter of `provideFactory` and `provideClass` methods. diff --git a/src/InjectorImpl.ts b/src/InjectorImpl.ts index 6f8db04..74a50fe 100644 --- a/src/InjectorImpl.ts +++ b/src/InjectorImpl.ts @@ -5,6 +5,7 @@ import { Injector } from './api/Injector'; import { Exception } from './Exception'; import { Disposable } from './api/Disposable'; import { isDisposable } from './utils'; +import { TChildContext } from './api/TChildContext'; const DEFAULT_SCOPE = Scope.Singleton; @@ -64,16 +65,17 @@ abstract class AbstractInjector implements Injector { }); } - public provideValue(token: Token, value: R): AbstractInjector<{ [k in Token]: R; } & TContext> { + public provideValue(token: Token, value: R) + : AbstractInjector> { return new ValueProvider(this, token, value); } public provideClass[]>(token: Token, Class: InjectableClass, scope = DEFAULT_SCOPE) - : AbstractInjector<{ [k in Token]: R; } & TContext> { + : AbstractInjector> { return new ClassProvider(this, token, scope, Class); } public provideFactory[]>(token: Token, factory: InjectableFunction, scope = DEFAULT_SCOPE) - : AbstractInjector<{ [k in Token]: R; } & TContext> { + : AbstractInjector> { return new FactoryProvider(this, token, scope, factory); } @@ -96,9 +98,7 @@ class RootInjector extends AbstractInjector<{}> { } } -type ChildContext = TParentContext & { [K in CurrentToken]: TProvided }; - -abstract class ChildInjector extends AbstractInjector> { +abstract class ChildInjector extends AbstractInjector> { private cached: { value?: any } | undefined; private readonly disposables = new Set(); @@ -114,16 +114,18 @@ abstract class ChildInjector>[]>(Class: InjectableClass, R, Tokens>, providedIn?: Function): R { + public injectClass + >[]>(Class: InjectableClass, R, Tokens>, providedIn?: Function): R { this.throwIfDisposed(Class); return super.injectClass(Class, providedIn); } - public injectFunction>[]>(fn: InjectableFunction, R, Tokens>, providedIn?: Function): R { + public injectFunction + >[]>(fn: InjectableFunction, R, Tokens>, providedIn?: Function): R { this.throwIfDisposed(fn); return super.injectFunction(fn, providedIn); } - public resolve>(token: Token, target?: Function): ChildContext[Token] { + public resolve>(token: Token, target?: Function): TChildContext[Token] { this.throwIfDisposed(token); return super.resolve(token, target); } @@ -155,19 +157,15 @@ abstract class ChildInjector>(token: SearchToken, target: Function | undefined) - : ChildContext[SearchToken] { + protected resolveInternal>(token: SearchToken, target: Function | undefined) + : TChildContext[SearchToken] { if (token === this.token) { if (this.cached) { return this.cached.value as any; } else { const value = this.result(target); - if (this.responsibleForDisposing && isDisposable(value)) { - this.disposables.add(value); - } - if (this.scope === Scope.Singleton) { - this.cached = { value }; - } + this.addToDisposablesIfNeeded(value); + this.addToCacheIfNeeded(value); return value as any; } } else { @@ -175,6 +173,18 @@ abstract class ChildInjector extends ChildInjector { @@ -196,7 +206,7 @@ class FactoryProvider; +export const rootInjector: Injector<{}> = new RootInjector(); diff --git a/src/api/Injector.ts b/src/api/Injector.ts index 5eb3c9b..934275c 100644 --- a/src/api/Injector.ts +++ b/src/api/Injector.ts @@ -1,15 +1,17 @@ import { InjectableClass, InjectableFunction } from './Injectable'; import { InjectionToken } from './InjectionToken'; import { Scope } from './Scope'; +import { TChildContext } from './TChildContext'; export interface Injector { injectClass[]>(Class: InjectableClass): R; injectFunction[]>(Class: InjectableFunction): R; resolve(token: Token): TContext[Token]; - provideValue(token: Token, value: R): Injector<{ [k in Token]: R } & TContext>; + provideValue(token: Token, value: R) + : Injector>; provideClass[]>(token: Token, Class: InjectableClass, scope?: Scope) - : Injector<{ [k in Token]: R } & TContext>; + : Injector>; provideFactory[]>(token: Token, factory: InjectableFunction, scope?: Scope) - : Injector<{ [k in Token]: R } & TContext>; + : Injector>; dispose(): Promise; } diff --git a/src/api/TChildContext.ts b/src/api/TChildContext.ts new file mode 100644 index 0000000..8054251 --- /dev/null +++ b/src/api/TChildContext.ts @@ -0,0 +1,3 @@ +export type TChildContext = { + [K in keyof (TParentContext & { [K in CurrentToken]: TProvided })]: K extends CurrentToken ? TProvided : K extends keyof TParentContext ? TParentContext[K]: never; +}; diff --git a/src/index.ts b/src/index.ts index de8eff2..f34a7bc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ export * from './api/CorrespondingType'; export * from './api/InjectionToken'; export * from './api/Injector'; export * from './api/Scope'; +export * from './api/TChildContext'; export * from './InjectorImpl'; export * from './tokens'; export * from './api/Disposable'; diff --git a/test/unit/Injector.spec.ts b/test/unit/Injector.spec.ts index 5bf42b3..c0a27bc 100644 --- a/test/unit/Injector.spec.ts +++ b/test/unit/Injector.spec.ts @@ -248,6 +248,28 @@ describe('InjectorImpl', () => { expect(() => sut.injectClass(class Bar { })).throws('Injector is already disposed. Please don\'t use it anymore. Tried to inject "Bar".'); expect(() => sut.injectFunction(function baz() { })).throws('Injector is already disposed. Please don\'t use it anymore. Tried to inject "baz".'); }); + + it('should be able to decorate an existing token', () => { + function incrementDecorator(n: number) { + return ++n; + } + incrementDecorator.inject = tokens('answer'); + + const answerProvider = rootInjector.provideValue('answer', 40) + .provideFactory('answer', incrementDecorator) + .provideFactory('answer', 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) + .provideValue('answer', '42'); + expect(answerProvider.resolve('answer')).eq('42'); + expect(typeof answerProvider.resolve('answer')).eq('string'); + }); }); describe('ClassProvider', () => { @@ -258,9 +280,26 @@ describe('InjectorImpl', () => { expect(() => sut.injectClass(class Bar { })).throws('Injector is already disposed. Please don\'t use it anymore. Tried to inject "Bar".'); expect(() => sut.injectFunction(function baz() { })).throws('Injector is already disposed. Please don\'t use it anymore. Tried to inject "baz".'); }); + + it('should be able to decorate an existing token', () => { + class Foo { + public static inject = tokens('answer'); + constructor(innerFoo: { answer: number }) { + this.answer = innerFoo.answer + 1; + } + public answer: number; + } + + const answerProvider = rootInjector.provideValue('answer', { answer: 40 }) + .provideClass('answer', Foo) + .provideClass('answer', Foo); + + expect(answerProvider.resolve('answer').answer).eq(42); + }); + }); - describe('dispose', () => { + describe(rootInjector.dispose.name, () => { it('should dispose all disposable singleton dependencies', async () => { // Arrange diff --git a/testResources/override-token-should-change-type.ts b/testResources/override-token-should-change-type.ts new file mode 100644 index 0000000..aefc75a --- /dev/null +++ b/testResources/override-token-should-change-type.ts @@ -0,0 +1,9 @@ +// error: "Type 'string' is not assignable to type 'number'" + +import { rootInjector } from '../src/index'; + +const fooProvider = rootInjector + .provideValue('foo', 42) + .provideValue('foo', 'bar'); + +const foo: number = fooProvider.resolve('foo'); diff --git a/testResources/tokens-of-type-string.ts b/testResources/tokens-of-type-string.ts index a93db81..366bd14 100644 --- a/testResources/tokens-of-type-string.ts +++ b/testResources/tokens-of-type-string.ts @@ -1,4 +1,4 @@ -// error: "Type 'string[]' is not assignable to type 'InjectionToken<{ bar: number; }>[]" +// error: "Type 'string[]' is not assignable to type 'InjectionToken>[]'" import { rootInjector } from '../src/index';