Skip to content

Commit

Permalink
feat(decorator): add decorator functionality (#6)
Browse files Browse the repository at this point in the history
Add the ability to override a token. Previously this resulted in a stack overflow error when resolving the token.

```ts
     const answerProvider = rootInjector
        .provideValue('answer', 42)
        .provideValue('answer', '42');
      expect(answerProvider.resolve('answer')).eq('42');
      expect(typeof answerProvider.resolve('answer')).eq('string');
```

With this functionality in place, it is now also possible to add decorators to existing dependencies. See readme for more info.
  • Loading branch information
nicojs committed May 3, 2019
1 parent 3ad6656 commit 1508107
Show file tree
Hide file tree
Showing 8 changed files with 126 additions and 25 deletions.
37 changes: 37 additions & 0 deletions README.md
Expand Up @@ -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.
Expand Down
50 changes: 30 additions & 20 deletions src/InjectorImpl.ts
Expand Up @@ -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;

Expand Down Expand Up @@ -64,16 +65,17 @@ abstract class AbstractInjector<TContext> implements Injector<TContext> {
});
}

public provideValue<Token extends string, R>(token: Token, value: R): AbstractInjector<{ [k in Token]: R; } & TContext> {
public provideValue<Token extends string, R>(token: Token, value: R)
: AbstractInjector<TChildContext<TContext, R, Token>> {
return new ValueProvider(this, token, value);
}

public provideClass<Token extends string, R, Tokens extends InjectionToken<TContext>[]>(token: Token, Class: InjectableClass<TContext, R, Tokens>, scope = DEFAULT_SCOPE)
: AbstractInjector<{ [k in Token]: R; } & TContext> {
: AbstractInjector<TChildContext<TContext, R, Token>> {
return new ClassProvider(this, token, scope, Class);
}
public provideFactory<Token extends string, R, Tokens extends InjectionToken<TContext>[]>(token: Token, factory: InjectableFunction<TContext, R, Tokens>, scope = DEFAULT_SCOPE)
: AbstractInjector<{ [k in Token]: R; } & TContext> {
: AbstractInjector<TChildContext<TContext, R, Token>> {
return new FactoryProvider(this, token, scope, factory);
}

Expand All @@ -96,9 +98,7 @@ class RootInjector extends AbstractInjector<{}> {
}
}

type ChildContext<TParentContext, TProvided, CurrentToken extends string> = TParentContext & { [K in CurrentToken]: TProvided };

abstract class ChildInjector<TParentContext, TProvided, CurrentToken extends string> extends AbstractInjector<ChildContext<TParentContext, TProvided, CurrentToken>> {
abstract class ChildInjector<TParentContext, TProvided, CurrentToken extends string> extends AbstractInjector<TChildContext<TParentContext, TProvided, CurrentToken>> {

private cached: { value?: any } | undefined;
private readonly disposables = new Set<Disposable>();
Expand All @@ -114,16 +114,18 @@ abstract class ChildInjector<TParentContext, TProvided, CurrentToken extends str

protected isDisposed = false;

public injectClass<R, Tokens extends InjectionToken<ChildContext<TParentContext, TProvided, CurrentToken>>[]>(Class: InjectableClass<ChildContext<TParentContext, TProvided, CurrentToken>, R, Tokens>, providedIn?: Function): R {
public injectClass
<R, Tokens extends InjectionToken<TChildContext<TParentContext, TProvided, CurrentToken>>[]>(Class: InjectableClass<TChildContext<TParentContext, TProvided, CurrentToken>, R, Tokens>, providedIn?: Function): R {
this.throwIfDisposed(Class);
return super.injectClass(Class, providedIn);
}
public injectFunction<R, Tokens extends InjectionToken<ChildContext<TParentContext, TProvided, CurrentToken>>[]>(fn: InjectableFunction<ChildContext<TParentContext, TProvided, CurrentToken>, R, Tokens>, providedIn?: Function): R {
public injectFunction
<R, Tokens extends InjectionToken<TChildContext<TParentContext, TProvided, CurrentToken>>[]>(fn: InjectableFunction<TChildContext<TParentContext, TProvided, CurrentToken>, R, Tokens>, providedIn?: Function): R {
this.throwIfDisposed(fn);
return super.injectFunction(fn, providedIn);
}

public resolve<Token extends keyof ChildContext<TParentContext, TProvided, CurrentToken>>(token: Token, target?: Function): ChildContext<TParentContext, TProvided, CurrentToken>[Token] {
public resolve<Token extends keyof TChildContext<TParentContext, TProvided, CurrentToken>>(token: Token, target?: Function): TChildContext<TParentContext, TProvided, CurrentToken>[Token] {
this.throwIfDisposed(token);
return super.resolve(token, target);
}
Expand Down Expand Up @@ -155,26 +157,34 @@ abstract class ChildInjector<TParentContext, TProvided, CurrentToken extends str
await Promise.all(promisesToAwait);
}

protected resolveInternal<SearchToken extends keyof ChildContext<TParentContext, TProvided, CurrentToken>>(token: SearchToken, target: Function | undefined)
: ChildContext<TParentContext, TProvided, CurrentToken>[SearchToken] {
protected resolveInternal<SearchToken extends keyof TChildContext<TParentContext, TProvided, CurrentToken>>(token: SearchToken, target: Function | undefined)
: TChildContext<TParentContext, TProvided, CurrentToken>[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 {
return this.parent.resolve(token as any, target) as any;
}
}

private addToCacheIfNeeded(value: TProvided) {
if (this.scope === Scope.Singleton) {
this.cached = { value };
}
}

private addToDisposablesIfNeeded(value: TProvided) {
if (this.responsibleForDisposing && isDisposable(value)) {
this.disposables.add(value);
}
}

}

class ValueProvider<TParentContext, TProvided, ProvidedToken extends string> extends ChildInjector<TParentContext, TProvided, ProvidedToken> {
Expand All @@ -196,7 +206,7 @@ class FactoryProvider<TParentContext, TProvided, ProvidedToken extends string, T
super(parent, token, scope);
}
protected result(target: Function): TProvided {
return this.injectFunction(this.injectable as any, target);
return this.parent.injectFunction(this.injectable, target);
}
protected readonly responsibleForDisposing = true;
}
Expand All @@ -209,9 +219,9 @@ class ClassProvider<TParentContext, TProvided, ProvidedToken extends string, Tok
super(parent, token, scope);
}
protected result(target: Function): TProvided {
return this.injectClass(this.injectable as any, target);
return this.parent.injectClass(this.injectable, target);
}
protected readonly responsibleForDisposing = true;
}

export const rootInjector = new RootInjector() as Injector<{}>;
export const rootInjector: Injector<{}> = new RootInjector();
8 changes: 5 additions & 3 deletions 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<TContext = {}> {
injectClass<R, Tokens extends InjectionToken<TContext>[]>(Class: InjectableClass<TContext, R, Tokens>): R;
injectFunction<R, Tokens extends InjectionToken<TContext>[]>(Class: InjectableFunction<TContext, R, Tokens>): R;
resolve<Token extends keyof TContext>(token: Token): TContext[Token];
provideValue<Token extends string, R>(token: Token, value: R): Injector<{ [k in Token]: R } & TContext>;
provideValue<Token extends string, R>(token: Token, value: R)
: Injector<TChildContext<TContext, R, Token>>;
provideClass<Token extends string, R, Tokens extends InjectionToken<TContext>[]>(token: Token, Class: InjectableClass<TContext, R, Tokens>, scope?: Scope)
: Injector<{ [k in Token]: R } & TContext>;
: Injector<TChildContext<TContext, R, Token>>;
provideFactory<Token extends string, R, Tokens extends InjectionToken<TContext>[]>(token: Token, factory: InjectableFunction<TContext, R, Tokens>, scope?: Scope)
: Injector<{ [k in Token]: R } & TContext>;
: Injector<TChildContext<TContext, R, Token>>;
dispose(): Promise<void>;
}
3 changes: 3 additions & 0 deletions src/api/TChildContext.ts
@@ -0,0 +1,3 @@
export type TChildContext<TParentContext, TProvided, CurrentToken extends string> = {
[K in keyof (TParentContext & { [K in CurrentToken]: TProvided })]: K extends CurrentToken ? TProvided : K extends keyof TParentContext ? TParentContext[K]: never;
};
1 change: 1 addition & 0 deletions src/index.ts
Expand Up @@ -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';
41 changes: 40 additions & 1 deletion test/unit/Injector.spec.ts
Expand Up @@ -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', () => {
Expand All @@ -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
Expand Down
9 changes: 9 additions & 0 deletions 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');
2 changes: 1 addition & 1 deletion 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<TChildContext<{}, number, \"bar\">>[]'"

import { rootInjector } from '../src/index';

Expand Down

0 comments on commit 1508107

Please sign in to comment.