Aurelia DI with typescript interfaces

2.7k views Asked by At

I've gone through the documentation of Aurelia DI and looked at the source code and wanted to share what I'm trying to achieve so that I can be shot down if I'm missing something obvious. I've looked at the samples here for TS with Aurelia but I can't see how it will work, and the docs are lacking.

What I want is:

dataProvider.js (the data provider interface)

export interface DataProvider {
  getData(): number;
}

itemDisplayer1.js (a class that will consume an injected class that implements the interface)

import {inject} from 'aurelia-framework';
import {DataProvider} from './dataProvider';

@inject(DataProvider)
export class itemDisplayer1 {
  constructor(public dataProvider: DataProvider) {
    this.dataProvider = dataProvider;
    this.data = dataProvider.getData();
  }
}

itemDisplayer2.js (another class that will consume an injected class that implements the interface)

import {inject} from 'aurelia-framework';
import {DataProvider} from './dataProvider';

@inject(DataProvider)
export class itemDisplayer2 {
  constructor(public dataProvider: DataProvider) {
    this.dataProvider = dataProvider;
    this.data = dataProvider.getData();
  }
}

GoodDataProvider.js

import {DataProvider} from "./dataProvider";

export class GoodDataProvider implements DataProvider {
  data = 1;
  getData() {
    return this.data;
  }
}

BetterDataProvider.js

import {DataProvider} from "./dataProvider";

export class BetterDataProvider implements DataProvider {
  data = 2;
  getData() {
    return this.data;
  }
}

And then somewhere(?) I would like to configure that itemDisplayer1 should be provided with an instance of GoodDataProvider and itemDisplayer2 should be provided with an instance of BetterDataProvider (1).

Then comes the problem of DI context. I'm not sure how to use container.createChild(). There's not much info on it that I can find. It creates a child container and it will delegate back to the parent when needed, but if I create 2 child containers and register one of the 2 providers with each child container, how would the itemDisplayer classes know which one to use (without changing their definitions and injecting in the parent container etc)?

Note: The lifetime management information doesn't live in the consumers or the providers of the dependencies (this is often done in the Aurelia DI examples and seems a little manufactured). I would expect this to be able to be defined when the consumers and providers are associated - point '(1)' above.

In summary, is this possible? Is this something that is on the cards for the near-ish future? Should I be trying to replace Aurelia DI with a custom container that meets my needs?

(The reason I'm trying to do this is that in order to evaluate js frameworks, the frameworks need to demonstrate a mature DI system with lifetime management/AOP etc capabilities as one of the criteria)

5

There are 5 answers

2
Mike Graham On

from @eisenbergeffect: The DI is going to get some internal overhaul once we get the benchmarks written.

But on a related note, it can’t work with interfaces because TypeScript compiles those away at runtime.

You would have to come up with unique keys when you register your different types in the DI container and then specify the appropriate unique key in the @Inject(xxx) statement. The keys can be anything you like. Normally folks use the type itself for the unique key (this causes some confusion), but you could use strings, numbers, or anything else you like.

the unit tests are informative also: https://github.com/aurelia/dependency-injection/blob/master/test/container.spec.js

0
Russell Seamer On

I had a different approach to solving this that worked for me.

Take the following class:

export class Foo implements Bar {

}

I changed this to the following:

import { Container } from 'aurelia-framework';

class Foo implements Bar {
}

export var foo = Container.instance.get(Foo) as Bar;

Now I can just do the following to get a typed singleton instance of the class:

import { foo } from 'foo';
0
Alon Bar On

Loved the idea of Frank Gambino and found a way to make it work both with @inject and @autoinject. The trick is to use a custom parameter decorator (since interface is reserved in TypeScript I called it @i).

The decorator parts:

myClass.ts

import { autoinject } from 'aurelia-framework';    
import { i } from './i.ts';
import { IFoo } from "./ifoo.ts";    

@autoinject
export class MyClass {
    constructor(@i(IFoo) foo: IFoo) {
        foo.doSomething();
    }
}

i.ts:

import "reflect-metadata";

/**
 * Declare the interface type of a parameter.
 *
 * To understand more about how or why it works read here:
 * https://www.typescriptlang.org/docs/handbook/decorators.html#metadata
 */
export function i(interfaceSymbol: symbol) {
    return function (target: Object, parameterName: string | symbol, parameterIndex: number) {           
        var paramTypes = Reflect.getMetadata('design:paramtypes', target);
        paramTypes[parameterIndex] = interfaceSymbol;
        Reflect.defineMetadata('design:paramtypes', paramTypes, target);
    }
}

The rest of it is exactly like Frank Gambino answer but I added it for completeness ...

ifoo.ts:

export interface IFoo {
    doSomething(): void;
}

export const IFoo = Symbol("IFoo"); // naming the symbol isn't mandatory, but it's easier to debug if something goes wrong

some.ts:

import { IFoo } from "./ifoo.ts";

export class Bar implements IFoo {
    doSomething(): void {
        console.log('it works!');
    }
}

main.ts:

import { IFoo } from "./ifoo.ts";
import { Bar } from "./bar.ts";

...

container.registerInstance(IFoo, new Bar());

...

And it can actually work with other DI containers. To make it work with Angular2 (although why would you? Aurelia is much more awesome :) you just need to change the type of interfaceSymbol in the i.ts file to any and instead of Symobl("IFoo") write new InjectionToken("IFoo") (the InjectionToken class is an Angular thingy and sadly enough they don't support Symbol as an injection token, at least for the time being).

2
dryajov On

So, as stated by others, TS compiles the interfaces aways and there is currently no way of doing this with pure interfaces. However, an interesting and often missed feature of TS is that it allows using class as an interface, this enables working around the current limitation.

export abstract class DataProvider {
  getData(): number;
}

@singleton(DataProvider) // register with an alternative key
export class MyAwesomeDataProvider implements DataProvider {
}

@autoinject
export class DataConsumer {
  constructor(dataProvider: DataProvider) {
  }
}

In the above code, we declare an abstract class DataProvider which will ensure that it's not compiled away by TS. We then register MyAwesomeDataProvider with an alternative key of DataProvider, which will return an instance of MyAwesomeDataProvider every time a DataProvider is requested.

As far as child containers go, you'd do container.createChild() which returns a new instance of the container and as long as the resolution is triggered from that child container, you should get the correct instance. The only problem is using decorators with two conflicting keys. Basically, the metadata lives on the class itself, so you can't have two instances registering under DataProvider, that would surely (tho I haven't tried it out myself) cause issues, the only way to go about it is use explicit registration. E.g.

export abstract class DataProvider {
  getData(): number;
}

export class MyAwesomeDataProvider implements DataProvider {
}

export class MyMoreAwesomeDataProvider implements DataProvider {
}        

child1 = container.createChild();
child1.registerSingleton(DataProvider, MyAwesomeDataProvider);

child2 = container.createChild();
child2.registerSingleton(DataProvider, MyMoreAwesomeDataProvider);

@autoinject
export class DataConsumer {
  constructor(dataProvider: DataProvider) {
  }
}

child1.get(DataConsumer); // injects MyAwesomeDataProvider
child2.get(DataConsumer); // injects MyMoreAwesomeDataProvider
11
Frank Gambino On

As Mike said, Aurelia doesn't support this dependency resolving feature yet. And interfaces get compiled away, so they cannot be used as keys (e.g. container.registerInstance(ISomething, new ConcreteSomething());

However, there is a small trick that can make it look like you're using the interface itself as the key.

foo.ts:

export interface IFoo {
  // interface
}

export const IFoo = Symbol();

bar.ts:

import {IFoo} from "./foo.ts";

export class Bar implements IFoo {
  // implementation
}

main.ts:

import {IFoo} from "./foo.ts";
import {Bar} from "./bar.ts";

...

container.registerInstance(IFoo, new Bar());

...

This compiles fine, and the compiler knows when to use the correct duplicate type based on the context in which it is used.