Injector

Light-weight and type-safe Dependency Injection.

Overview

Simple, light-weight, lazy-instantiating, and type-safe dependency injection in Typescript! Perfect for use in Lambdas and unit test friendly.

It is built on top of BottleJS, with a simple type-safe wrapper. The original bottle is available for more advanced use, though. Even if you are not using Typescript, you may still prefer this simplified interface over using BottleJS directly.

As of v3, Injector also supports a Typescript decorator for registering classes.

Injector depends on one other utility to work:

Install

npm install @sailplane/injector @sailplane/logger [email protected]

Configuration

To use the Typescript decorator, these options must be enabled in tsconfig.json:

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

If using esbuild, a plugin such as esbuild-decorators is necessary.

Usage with Examples

Register a class with no dependencies and retrieve it

Use Injector.register(className) to register a class with the Injector. Upon the first call to Injector.get(className), the singleton instance will be created and returned.

Example without decorator:

import {Injector} from "@sailplane/injector";

export class MyService {
}
Injector.register(MyService);

// Later...
const myService = Injector.get(MyService)!;

Example with decorator:

import {Injector, Injectable} from "@sailplane/injector";

@Injectable()
export class MyService {
}

// Later...
const myService = Injector.get(MyService)!;

See the next section for another example of receiving a registered class as a dependency.

Register a class with an array of dependencies

Use Injector.register(className, dependencies: []) to register a class with constructor dependencies with the Injector. Upon the first call to Injector.get(className), the singleton instance will be created and returned.

dependencies is an array of either class names or strings with the names of things.

Example without decorator:

import {Injector} from "@sailplane/injector";

export class MyHelper {
}
Injector.register(MyHelper);
Injector.registerConstant('stage', 'dev');

export class MyService {
    constructor(private readonly helper: MyHelper,
                private readonly stage: string) {
    }
}
Injector.register(MyService, [MyHelper, 'stage']);

Example with decorator:

import {Injector, Injectable} from "@sailplane/injector";

@Injectable()
export class MyHelper {
}
Injector.registerConstant('stage', 'dev');

@Injectable({dependencies: [MyHelper, 'stage']})
export class MyService {
    constructor(private readonly helper: MyHelper,
                private readonly stage: string) {
    }
}

Register a class with static $inject array

Define your class with a static $inject member as an array of either class names or strings with the names of registered dependencies, and a matching constructor that accepts the dependencies in the same order. Then use Injector.register(className) to register a class. Upon the first call to Injector.get(className), the singleton instance will be created with the specified dependencies.

This functions just like the previous use, but allows you to specify the dependencies right next to the constructor instead of after the class definition; thus making it easier to keep the two lists synchronized.

Example:

import {Injector} from "@sailplane/injector";

export class MyHelper {
}
Injector.register(MyHelper);
Injector.registerConstant('stage', 'dev');

export class MyService {
    static readonly $inject = [MyHelper, 'stage'];
    constructor(private readonly helper: MyHelper,
                private readonly stage: string) {
    }
}
Injector.register(MyService);

Register a class with a factory

If your class takes constructor parameters that are not in the dependency system, then you can register a factory function.

Use Injector.register<T>(className, factory: ()=>T) to register a class with your own factory function for instantiating the singleton instance.

Example without decorator:

import {Injector} from "@sailplane/injector";

export class MyHelper {
}
Injector.register(MyHelper);

export class MyService {
    constructor(private readonly helper: MyHelper,
                private readonly stage: string) {
    }
}
Injector.register(MyService,
                  () => new MyService(Injector.get(MyHelper)!, process.env.STAGE!));

Example with decorator:

import {Injector, Injectable} from "@sailplane/injector";

@Injectable()
export class MyHelper {
}

@Injectable({factory: () => new MyService(Injector.get(MyHelper)!, process.env.STAGE!)})
export class MyService {
    constructor(private readonly helper: MyHelper,
                private readonly stage: string) {
    }
}

Register a child class that implements a parent

A common dependency injection pattern is to invent code dependencies by having business logic define an interface it needs to talk to via an abstract class, and then elsewhere have a child class define the actual behavior. Runtime options could even choose between implementations.

Here’s the business logic code:

abstract class SpecialDataRepository {
  abstract get(id: string): Promise<SpecialData>;
}

class SpecialBizLogicService {
  constructor(dataRepo: SpecialDataRepository) {}
  public async calculate(id: string) {
    const data = await this.dataRepo.get(id);
    // do stuff
  }
}
Injector.register(SpecialBizLogicService); // Could use @Injectable() instead

Without decorators, we use Injector.register<T>(className, factory: ()=>T) to register the implementing repository, which could be done conditionally:

Example without decorator:

import {Injector} from "@sailplane/injector";

export class LocalDataRepository extends SpecialDataRepository {
  async get(id: string): Promise<SpecialData> {
    // implementation ....
  }
}

export class RemoteDataRepository extends SpecialDataRepository {
  async get(id: string): Promise<SpecialData> {
    // implementation ....
  }
}

const isLocal = !!process.env.SHELL;
Injector.register(
  MyService,
  () => isLocal ? new LocalDataRepository() : new RemoteDataRepository()
);

Example with decorator (can’t be conditional):

import {Injector, Injectable} from "@sailplane/injector";

@Injectable({as: SpecialDataRepository})
export class RemoteDataRepository extends SpecialDataRepository {
  async get(id: string): Promise<SpecialData> {
    // implementation ....
  }
}

Register anything with a factory and fetch it by name

If you need to inject something other than a class, you can register a factory to create anything and give it a name. This is useful if you have multiple implementations of an _interface_, and one to register one of them by the interface name at runtime. Since interfaces don’t exist at runtime (they don’t exist in Javascript), you must define the name yourself. (See previous example using an abstract base class for a more type-safe approach.)

Use Injector.registerFactory<T>(name: string, factory: ()=>T) to register any object with your own factory function for returning the singleton instance.

Example: Inject a configuration

import {Injector} from "@sailplane/injector";

Injector.registerFactory('config', () => {
    // Note that this returns a Promise
    return Injector.get(StateStorage)!.get('MyService', 'config');
});

// Later...

const config = await Injector.getByName('config');

Example: Inject an interface implementation, conditionally and no decorator

import {Injector} from "@sailplane/injector";

export interface FoobarService {
    doSomething(): void;
}

export class FoobarServiceImpl implements FoobarService {
    constructor(private readonly stateStorage: StateStorage) {}

    doSomething(): void {
        this.stateStorage.set('foobar', 'did-it', 'true');
    }
}

export class FoobarServiceDemo implements FoobarService {
    doSomething(): void {
        console.log("Nothing really");
    }
}

Injector.registerFactory('FoobarService', () => {
    if (process.env.DEMO! === 'true') {
        return new FoobarServiceDemo();
    }
    else {
        return new FoobarServiceImpl(Injector.get(StateStorage)!);
    }
});

// Elsewhere...

export class MyService {
    static readonly $inject = ['FoobarService']; // Note: This is a string!
    constructor(private readonly foobarSvc: FoobarService) {
    }
}
Injector.register(MyService);

Example: Inject an interface implementation with the decorator

import {Injector, Injectable} from "@sailplane/injector";

export interface FoobarService {
    doSomething(): void;
}

@Injectable({as: "FoobarService"})
export class FoobarServiceImpl implements FoobarService {
    constructor(private readonly stateStorage: StateStorage) {}

    doSomething(): void {
        // code
    }
}

// Elsewhere...

@Injectable({dependencies: ['FoobarService']}) // Note: This is a string!
export class MyService {
    constructor(private readonly foobarSvc: FoobarService) {
    }
}
Injector.register(MyService);

Register a constant value and fetch it by name

Use Injector.registerConstant<T>(name: string, value: T) to register any object as a defined value. Unlike all other registrations, the value is passed in rather than being lazy-created.

Example:

import {Injector} from "@sailplane/injector";
import {environment} from "environment";

Injector.registerConstant('environment-config', environment);

// Later...

const myEnv = Injector.getByName('environment-config');

More Examples

See More Examples for another example.

Dependency Evaluation

Dependencies are not evaluated until the class is instantiated, thus the order of registration does not matter.

Cyclic constructor dependencies will fail. This probably indicates a design problem, but you can break the cycle by calling Injector.get(className) when needed (outside of the constructor), instead of injecting into the constructor.

This is a perfectly valid way of using Injector (on demand rather than upon construction). It does require that unit test mocks be registered with the Injector rather than passed into the constructor.

Type Declarations

/**
 * Dependency Injection (DI) injector.
 *
 * Using BottleJs because it is _extremely_ light weight and lazy-instantiate,
 * unlike any Typescript-specific solutions.
 *
 * @see https://github.com/young-steveo/bottlejs
 */
import "reflect-metadata";
import * as Bottle from 'bottlejs';
declare type InjectableClass<T> = {
    new (...args: any[]): T;
    $inject?: DependencyList;
    name: string;
};
declare type GettableClass<T> = Function & {
    prototype: T;
    name: string;
};
declare type DependencyList = (InjectableClass<unknown> | string)[];
/**
 * Wraps up type-safe version of BottleJs for common uses.
 *
 * The raw bottle is also available.
 */
export declare class Injector {
    static readonly bottle: Bottle<string>;
    /**
     * This may be called at beginning of process.
     * @deprecated
     */
    static initialize(): void;
    /**
     * Register a class.
     *
     * Example service that lazy instantiates with no arguments:
     * - Injector.register(MyServiceClass);
     *
     * Example service that lazy instantiates with other registered items as arguments to
     * the constructor:
     * - Injector.register(MyServiceClass, [DependentClass, OtherClass, 'constant-name']);
     *
     * Example service that lazy instantiates with other registered items specified
     * as $inject:
     * - class MyServiceClass {
     * -   static readonly $inject = [OtherClass, 'constant-name'];
     * -   constructor(other, constValue) {};
     * - }
     * - Injector.register(MyServiceClass);
     *
     * Example service that lazy instantiates with a factory function:
     * - Injector.register(MyServiceClass,
     * -                   () => new MyServiceClass(Injector.get(OtherClass)!, MyArg));
     *
     * @param clazz the class to register. Ex: MyClass. NOT an instance, the class.
     * @param factoryOrDependencies see above examples. Optional.
     * @param asName by default is clazz.name, but may specify another name to register as
     * @throws TypeError if duplicate or bad request
     */
    static register<T>(clazz: InjectableClass<T>, factoryOrDependencies?: (() => T) | DependencyList, asName?: string): void;
    /**
     * Register a factory by name.
     *
     * Example:
     * - Injector.registerFactory('MyThing', () => Things.getOne());
     *
     * @see #getByName(name)
     * @param name name to give the inject.
     * @param factory function that returns a class.
     * @throws TypeError if duplicate or bad request
     */
    static registerFactory<T>(name: string, factory: (() => T)): void;
    /**
     * Register a named constant value.
     *
     * @see #getByName(name)
     * @param name name to give this constant.
     * @param value value to return when the name is requested.
     * @throws TypeError if duplicate or bad request
     */
    static registerConstant<T>(name: string, value: T): void;
    /**
     * Get instance of a class.
     * Will instantiate on first request.
     *
     * @param clazz the class to fetch. Ex: MyClass. NOT an instance, the class.
     * @return the singleton instance of the requested class, undefined if not registered.
     */
    static get<T>(clazz: GettableClass<T>): T | undefined;
    /**
     * Get a registered constant or class by name.
     *
     * @see #registerFactory(name, factory, force)
     * @see #registerConstant(name, value, force)
     * @param name
     * @return the singleton instance registered under the given name,
     *         undefined if not registered.
     */
    static getByName<T>(name: string): T | undefined;
    /**
     * Is a class, factory, or constant registered?
     * Unlike #getByName, will not instantiate if registered but not yet lazy created.
     *
     * @param clazzOrName class or name of factory or constant
     */
    static isRegistered(clazzOrName: InjectableClass<unknown> | GettableClass<unknown> | string): boolean;
}
/** Options for Injectable decorator */
export interface InjectableOptions<T> {
    /**
     * Register "as" this parent class or name.
     * A class *must* be a parent class.
     * The name string works for interfaces, but lacks type safety.
     */
    as?: GettableClass<unknown> | string;
    /** Don't auto-detect constructor dependencies - use this factory function instead */
    factory?: () => T;
    /** Don't auto-detect constructor dependencies - use these instead */
    dependencies?: DependencyList;
}
/**
 * Typescript Decorator for registering classes for injection.
 *
 * Must enable options in tsconfig.json:
 * {
 *   "compilerOptions": {
 *     "experimentalDecorators": true,
 *     "emitDecoratorMetadata": true
 *   }
 * }
 *
 * Usage:
 *
 * Like Injector.register(MyServiceClass, [Dependency1, Dependency2]
 *   @Injectable()
 *   class MyServiceClass {
 *       constructor(one: Dependency1, two: Dependency2) {}
 *   }
 *
 * Like Injector.register(MyServiceClass, [Dependency1, "registered-constant"]
 *   @Injectable({dependencies=[Dependency1, "registered-constant"]})
 *   class MyServiceClass {
 *       constructor(one: Dependency1, two: string) {}
 *   }
 *
 * Like Injector.register(MyServiceClass, () = new MyServiceClass())
 *   @Injectable({factory: () = new MyServiceClass()})
 *   class MyServiceClass {
 *   }
 *
 * Like Injector.register(HexagonalPort, () => new HexagonalAdaptor())
 *   abstract class HexagonalPort {
 *     abstract getThing(): string;
 *   }
 *   @Injectable({as: HexagonalPort })
 *   class HexagonalAdaptor extends HexagonalPort {
 *     getThing() { return "thing"; }
 *   }
 */
export declare function Injectable<T>(options?: InjectableOptions<T>): (target: InjectableClass<unknown>) => void;
export {};