Skip to content

Instantly share code, notes, and snippets.

@422404
Created June 13, 2018 21:05
Show Gist options
  • Save 422404/91c2220197035d678cf1df7eceae4993 to your computer and use it in GitHub Desktop.
Save 422404/91c2220197035d678cf1df7eceae4993 to your computer and use it in GitHub Desktop.
JavaScript Dependency Injection™ using Typescript decorators

Don't forget to enable experimentalDecorators in tsconfig.json !

/*********************************** Typing ***************************************/
/**
* Signature of a constructible class
*/
interface IClass {
/**
* Used to express the constructability of a class
*/
new(...args: any[]): {};
}
/**
* Signature of a service factory class
*/
interface IServiceFactory extends IClass {
/**
* Construct and return the instance of the class constructed by the factory
* @param {any} args Factories are free to require any args
* @returns {Object} Newly created instance
*/
getInstance: (...args: any[]) => Object;
}
/**
* Signature of a registered service
* Handful for IntelliSence
*/
interface IService extends IClass {
/**
* Service identifier
* Not really optionnal, just because classes are modified at runtime
* @type {UID}
*/
$$uid?: UID,
/**
* Must be equal to "service"
* Not really optionnal, just because classes are modified at runtime
* @type {string}
*/
$$type?: 'service'
}
/**
* Config that ndicate which factory to use to create the service instance the
* consumer depends on
*/
type IServiceFactoryConfig = { factory: IServiceFactory, args?: any[] }
/**
* Config that indicate the service instance or factory to use to create
* the service instance the consumer depends on
*/
type IServiceInjectionConfig = IService | IServiceFactoryConfig
/**
* A unique identifier
*/
type UID = number
/*********************************** Private **************************************/
/**
* Injects arguments into factory "getInstance" method
* @param {any} args Arguments to inject, only services in this case
* @param {IServiceFactory} factoryClass Factory's class
* @returns {IServiceFactory} The factory class with injected args into "getInstance" method
*/
function injectArgsIntoFactory(args: any[], factoryClass: IServiceFactory): IServiceFactory {
let getInstanceFn: Function = factoryClass.getInstance;
if (!getInstanceFn) {
throw 'The given class is not a factory !';
}
factoryClass.getInstance = getInstanceFn.bind(factoryClass, ...args);
return factoryClass;
}
/**
* Injects arguments into a constructor
* @param {any} args Arguments to inject into the constructor, only services in this case
* @param {IClass} baseClass Class to inject the args into
* @returns {IClass} The new class with the args injected into its constructor
*/
function injectArgsIntoConstructor(args: Object[], baseClass: IClass): IClass {
return class extends baseClass {
constructor(..._args: any[]) {
super(...args, ..._args);
}
};
}
/**
* Collects the services instances required by the config
* @param {IServiceInjectionConfig[]} dependanciesConfig Config containing the services
* or factories to use to retrieve the services instances
* @returns {Object[]} The services instances
*/
function collectServices(dependanciesConfig: IServiceInjectionConfig[]): Object[] {
return dependanciesConfig.map(service => constructServiceOrDelegateToFactory(service));
}
/**
* Constructs a service from the config
* @param {IServiceInjectionConfig} dependencyConfig Service class or service factory config
* @returns {Object} The service instance
*/
function constructServiceOrDelegateToFactory(dependencyConfig: IServiceInjectionConfig): Object {
let serviceInstance: Object;
if (typeof dependencyConfig === 'object') {
serviceInstance = dependencyConfig.factory.getInstance.apply(null,
dependencyConfig.args !== undefined ? dependencyConfig.args : []
);
} else if (typeof dependencyConfig === 'function') {
if (!dependencyConfig.$$uid || !dependencyConfig.$$type || dependencyConfig.$$type != 'service') {
throw 'Service not registered !';
}
let serviceCtor: IClass = <IClass>ServiceRegistry.getService(dependencyConfig.$$uid);
serviceInstance = new serviceCtor();
} else {
throw 'Bad config !';
}
return serviceInstance;
}
/**
* Represents a registered service
* @class
*/
class RegisteredService {
/**
* @constructor
* @param {UID} uid Unique identifier of the registered service
* @param {IClass} klass Service's class
*/
constructor(public uid: UID, public klass: IClass) {
}
}
/**
* Registry of services
* @class
*/
class ServiceRegistry {
/**
* The registered services
* @static
* @type {RegisteredService[]}
*/
private static services: RegisteredService[] = [];
/**
* Current uid to be used
* @static
* @type {UID}
*/
private static currentUID: UID = 0;
/**
* Generates a new uid and returns it
* @static
* @returns {UID} The new uid
*/
static generateUID() {
return ++ServiceRegistry.currentUID;
}
/**
* Registers a service
* @param {IClass} service Service's class
* @returns {IClass} The new class with $$uid and $$type properties added
*/
static registerService(service: IClass): IClass {
let uid = ServiceRegistry.generateUID();
(<any>service).$$uid = uid;
(<any>service).$$type = 'service';
ServiceRegistry.services.push(new RegisteredService(uid, service));
return service;
}
/**
* Returns the class of a registered service
* @param {UID} uid The unique id of the service
* @returns {IClass} The registered service's class
*/
static getService(uid: UID): IClass {
let service: RegisteredService|undefined = ServiceRegistry.services.find(service =>
service.uid == uid
);
if (!service) {
throw 'Unregistered service !';
}
return service.klass;
}
};
/*********************************** Public ***************************************/
/**
* Service decorator
* Used to register a class as a service
*/
export function Service(): Function {
return function (constructor: IClass): IClass {
return ServiceRegistry.registerService(constructor);
};
}
/**
* Service consumer decorator
* Performs dependancies (services) injection into a class constructor
* @param {IServiceInjectionConfig[]} needs Array of services or services factories config
* Array items can be either services classes (i.e: UserService) or objects thats specifies
* the factory to use and its optionnal arguments (i.e: { factory : UserServiceFactory [, args: [some arg, another]] })
*/
export function ServicesConsumer(needs: IServiceInjectionConfig[]): Function {
if (!needs) {
throw 'To few arguments given !';
}
return function (constructor: IClass): IClass {
let services: Object[] = collectServices(needs);
return injectArgsIntoConstructor(services, constructor);
};
}
/**
* Service factory decorator
* Indicates that a static class is a service factory (do not register it) and
* performs dependancies injection into the "getInstance" method used to construct
* the provided service
* @param needs Array of services or services factories config
* Array items can be either services classes (i.e: UserService) or objects thats specifies
* the factory to use and its optionnal arguments (i.e: { factory : UserServiceFactory [, args: [some arg, another]] })
*/
export function ServiceFactory(needs?: IServiceInjectionConfig[]): Function {
if (!needs) {
return;
}
return function (constructor: IServiceFactory): IServiceFactory {
let services: Object[] = collectServices(needs);
return injectArgsIntoFactory(services, constructor);
}
}
import { Service, ServicesConsumer, ServiceFactory } from './dependencyInjection';
/**
* Whether or not the user is logged
* (such authentication)
*/
const AUTHENTICATED = false;
/**
* Base language of the strings
*/
const LANG = 'fr';
/**
* Base strings in English and their translation in French and Spanish
*/
const strings: Object = {
'<anonymous>': {
'fr': '<anonyme>',
'es': '<anónimo>'
},
'Johnatan': null,
'Hello': {
'fr': 'Salut',
'es': 'Hola'
}
};
/**
* Internationalization service
* @class
*/
@Service()
class I18nService {
private baseLang: string = 'en';
/**
* @constructor
* @param {string} [lang] Language to translate the text into
* @param {Object} [strings] Object that contains base strings and their translations
*/
constructor(private lang?: string, private strings?: any) {}
/**
* Translate a text into other languages
* @param text Text to translate
* @returns {string} translated text or base text if no translations
*/
$(text: string): string {
if (!this.lang
|| !this.strings
|| this.lang == this.baseLang
|| !this.strings[text]
|| !this.strings[text][this.lang]) {
return text;
}
return this.strings[text][this.lang];
}
}
/**
* Basic authentication service for the purpose of testing
* @class
*/
@Service()
class AuthService {
/**
* Returns whether or not the user is authenticated
* @
*/
isAuthenticated(): boolean {
return AUTHENTICATED;
}
}
/**
* Constructs I18nService instance with more control
* @class
*/
@ServiceFactory()
class I18nServiceFactory {
/**
* Creates an instance of I18nService
* @static
* @param {string} lang Language used to translate text
* @returns {Object} I18nService instance
*/
static getInstance(lang: string): Object {
return new I18nService(lang, strings);
}
}
/**
* Constructs UserService instance with more control
* @class
*/
@ServiceFactory([
AuthService,
{ factory: I18nServiceFactory, args: [LANG] }
])
class UserServiceFactory {
/**
* Creates an instance of UserService
* @static
* @param {AuthService} auth Some authentification service
* @param {I18nService} i18n I18n service dependency used to print internationalized text
*/
static getInstance(auth: AuthService, i18n: I18nService): Object {
return new UserService(i18n, auth.isAuthenticated() ? i18n.$('Johnatan') : null);
}
}
/**
* Cool service sayin' "Hello"
* @class
*/
@Service()
@ServicesConsumer([
{ factory: I18nServiceFactory, args: [LANG] }
])
class HelloService {
/**
* @constructor
* @param {I18nService} i18n I18n service dependency used to print internationalized text
*/
constructor(private i18n: I18nService) {}
/**
* Say hello !
* @returns {string} the "Hello" string translated or not
*/
sayHello(): string {
return this.i18n.$('Hello');
}
};
/**
* Service providing informations about an user (logged or not)
* @class
*/
@Service()
class UserService {
/**
* @constructor
* @param {I18nService} i18n I18n service dependency used to print internationalized text
* @param {string} [username] name of the logged in user
*/
constructor(private i18n: I18nService, private username?: string) {}
/**
* Shows the current user's name
* @returns {string} current user's name
*/
showUser(): string {
return this.username ? this.username : this.i18n.$('<anonymous>');
}
}
/**
* Some component using dependency injection
* @class
*/
@ServicesConsumer([
HelloService,
{ factory: UserServiceFactory }
])
class MyComponent {
/**
* @constructor
* @param {HelloService} [hello] Some hello service dependency
* Just for the test it's made optionnal as we construct the component by hand
* @param {User} [user] Some user service dependency
* Just for the test it's made optionnal as we construct the component by hand
*/
constructor(hello?: HelloService, user?: UserService) {
console.log(hello.sayHello(), user.showUser(), '!');
}
}
// Should be delegated to a component builder
let component: MyComponent = new MyComponent();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment