Skip to content

Small object factory & container with compile-time type checking and lifetime management for TypeScript

Notifications You must be signed in to change notification settings

amadare42/typesafe-container

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

14 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Typesafe Container


npm | github

What is it?

typesafe-container is small (2.27kb min, 0.89kb min+gzip) library for managing object creation with lifetime management for TypeScript. It relays on TypeScript's implicit type inferring in order to provide full edit-time resolution of dependency graph.

Features

  • edit time resolution: if container declaration is resolved during edit time then it can resolve objects at runtime
  • object creation freedom: container user should not know or care if object is created by fetching lazy-loaded script or by just calling new
  • minimalistic & transparent: api is as simple as it gets - object creation can be complex by itself, so it don't add additional layer of complexity on top of it - you see what it does.
  • small footprint: don't require anything special for object declaration or creation - just separating object creation into different layer. It's simple to add, simple to remove.
  • bundle size friendly: it will not impact your bundle size on initial addition nor on further usage

Comparison with "proper" DI-containers

I tried to create simplest possible way to create and manage object with complex dependencies with taking full advantage of typing system. Let's compare it with existing DI-containers for typescript like Inversify or tsyringe. Here is example of common way of using Inversify approach:

const TYPES = {
    Weapon: Symbol.for('Weapon')
};

@injectable()
class Ninja {
    public constructor(@inject(TYPES.Weapon) private katana: Weapon) {}
}

I stumbled upon following problems, while using it:

  1. No native runtime typings leads to cumbersome declarations

We cannot just bind interface to it's implementation in a way we would do it in statically-typed languages like C#, since there is no type metadata in compile time (with some exceptions like awesome Angular DI with special AST-parsing magic). So in order to tell container how to resolve arguments, we have to add a this metadata manually in form of @inject() attributes. This adds noise to your code and can make even a simple constructor looking complex and hard to read.

  1. Requires decorators

You have to emit typescript metadata for each class with dependency. This not only increases application size (quite noticeably sometimes), but also require some not-so-convenient registration call if for some reason you're unable to add decorators.

  1. Dynamic nature means limited compile-time typings support

When you're injecting instance by dynamic locator, you're losing compile-time typing support and can get unexpected errors in runtime: developer can write wrong interface or identifier (e.g. Sword instead of Weapon as katana type) and typescript will not be able to warn user about incorrect typing.

That also means, there is no clear indication that some dependencies were broken during container refactoring, since there is no way to automatically check that all types of dependencies are correct.


typesafe-container is solving all these problems while being very small and simple.

How to use it?

You can find step-by-step examples below, but feel free to jump straight to API reference.

01. Simple example

const container = new ContainerBuilder()
    .register(register => ({
        logger: register.singleton(() => ({
            log: a => console.log(a)
        })),
        currentTime: register.transient(() => new Date().toUTCString())
    }))
    .getContainer();
const logger = container.logger();
logger.log(container.currentTime()); // will output current time T
// <wait for 1 second>
logger.log(container.currentTime()); // will output T+1 second
  • container works like service locator that will abstract away details of object creation
  • ContainerBuilder will provide interface to create container typed container that contains dependencies that are included in modules. In our example it means that container will have currentTime as method that returns string.
  • ContainerBuilder.register() function is registering new module to container. It accept registration function as argument, which returns object, every field of which is registered dependency. We added currentTime as one. For full API description, check API reference.
  • register.transient means that we're registering dependency in transient scope. This means that specified factory method will be called every time this object is requested.
  • register.singleton means that specified factory method will be called first time and will be pulled from cache for other calls. For list of all lifetime scopes, check API reference.

02. Dependency injection & Modules

Let's introduce currentDate dependency that will add formatting for our date:

class DateModule extends BaseModule {
    currentTime = this.register.transient(() => new Date().toUTCString());
    timeString = this.register.transient(ctr => `Current date: ${ctr.currentTime()}`, this)
}

const container = new ContainerBuilder()
    .register(register => new DateModule(register))
    .getContainer();

// will output 'Current date: N', where N is current time
console.log(container.timeString());
  • BaseModule is just simple implementation for module. As you saw from example earlier, "module" doesn't require any special fields and can be just plain object, but for code structuring & code splitting, we can use this abstraction.
  • register.transient factory method can receive container as argument. It can use it to resolve dependencies that are already registered in container. In our case timeString will resolve currentTime and add formatting for it.

NOTE: while you can use this to resolve dependencies from current module, I'll recommend using container as it will contain typed dependencies from current AND other modules.

You can also use shorthand syntax like this:

const container = new ContainerBuilder()
    .module(DateModule)
    .getContainer();

//...

03. Module Dependencies

class EnvironmentModule extends BaseModule {
    currentTime = this.register.transient(() => new Date().toUTCString());
}

class DateModule extends BaseModule<EnvironmentModule> {
    timeString = this.register.transient(container => `Current date: ${container.currentTime()}`)
}

const container = new ContainerBuilder()
    .register(register => new EnvironmentModule(register))
    .register(register => new DateModule(register))
    .getContainer();

const wontCompile = new ContainerBuilder()
    .register(register => new DateModule(register)) // this will not compile since EnvironmentModule is not added yet
    .register(register => new EnvironmentModule(register))
    .getContainer();
  • ContainerBuilder can register multiple modules into single container and will adjust it's typings accordingly.
  • Module can specify it's dependency on other module. In BaseModule's case it it's generic argument.
  • Module registration is sequential, so all dependencies of each module have to be satisfied before it can be added.

04. Module monikers

We can also add monikers (alternative keys for modules), so whole module can be available by that name in addition to global container scope.

class EnvironmentModule extends BaseModule {
    currentTime = this.register.transient(() => new Date().toUTCString());
}

class DateModule extends BaseModule<{ env: EnvironmentModule }> {
    timeString = this.register.transient(({env}) => `Current date: ${env.currentTime()}`)
}

const container = new ContainerBuilder()
    .register(register => new EnvironmentModule(register), ['env'])
    .register(register => new DateModule(register))
    .getContainer();

const wontCompile = new ContainerBuilder()
    .register(register => new EnvironmentModule(register))
    // this will not compile since DateModule expects EnvironmentModule to be under 'env' key
    .register(register => new DateModule(register))
    .getContainer();
  • You can have multiple monikers as string or symbols for same module

05. Custom scopes

If we need to dynamically control lifetime of object, we can use custom scopes:

class TimeFreezeScope implements ContainerScope {
    isTimeFrozen: boolean = true;
    shouldCreateNew = () => !this.isTimeFrozen;
    toggleTimeFreeze = () => this.isTimeFrozen = !this.isTimeFrozen;
}

class TimeModule extends BaseModule {
    timeFreezeScope = this.register.singleton(() => new TimeFreezeScope());
    realDate = this.register.transient(() => new Date().toUTCString());
    currentDate = this.register.inScopeOf(ctr => ctr.timeFreezeScope(), ctr => ctr.realDate(), this);
}
const container = new ContainerBuilder()
    .register(r => new TimeModule(r))
    .getContainer();

// will print current time T, because of first initialization
console.log(container.currentDate());
// will print same time T, because shouldCreateNew returned false
console.log(container.currentDate());
// will print T+elapsed time, since realDate declared without scope
console.log(container.realDate());

container.timeFreezeScope().toggleTimeFreeze();

// will print T+elapsed time, because shouldCreateNew returned true
console.log(container.currentDate());
  • You can set custom objects lifetime based on ContainerScope interface
  • ContainerScope controls only cache, not object creation. So object will be created on first call regardless of it's value

06. Stateful module

If for some reason module needs to have some state on initialization, we can use stateful module like so:

interface MyState {
    foo: string;
}

class MyStatefulModule extends StatefulModule<MyState> {
    bar = this.register.const('bar');
    foobar = this.register.singleton(ctx => this.state.foo + ctx.bar, this);
}

const state = { foo: 'foo' };
const container = new ContainerBuilder()
    .register(r => new MyStatefulModule(r, state))
    .getContainer();

// prints 'foobar'
console.log(container.foobar());

Hints / Advanced topics

Dependency on multiple modules

When your module is dependent on multiple other modules, you can simply create intersection type from them:

class ModuleA extends BaseModule {}
class ModuleB extends BaseModule {}
class ModuleC extends BaseModule<ModuleA & ModuleC> {}

Dependency on own module: why this?

TypeScript compiler refuses to infer the type of an object literal if the inferred type references itself. So in order to reference current module, you have to help it to infer properly. Functions will require explicit this as last parameter.

class MyModule extends BaseModule {
    foo = this.register.singleton(() => 42);
    // 'this' argument is required to use `foo` there
    bar = this.register.singleton(ctr => ctr.foo(), this);
}

This argument will not be actually used in code. It's there just for compiler. You could also just use this instead of ctr in that case, but I'll recommend strongly against it. You will have to edit a lot more wiring code after moving dependencies.

Preventing names collision

Since we have single namespace, you can stumble upon naming collision problem if you have a lot of services. There are some ways to tackle this problem:

  1. Using Module monikers
  2. Using symbols as keys instead of strings inside container (like in Inversify):
// ./types/samurai.ts
export const Weapon = Symbol.for('Weapon')

// ./types/bowman.ts
export const Weapon = Symbol.for('Weapon')

// ./container.ts
import * as Samurai from './types/samurai.ts'
import * as Bowman from './types/bowman.ts'

class SamuraiModule extends BaseModule {
    [Samurai.Weapon] = this.register.transient(() => 'Katana')
}

class BowmanModule extends BaseModule {
    [Bowman.Weapon] = this.register.transient(() => 'Bow')
}

const container = new ContainerBuilder()
    .register(r => new SamuraiModule(r))
    .register(r => new BowmanModule(r))
    .getContainer();

console.log(container[Bowman.Weapon]()); // prints 'Bow'

Note how I used namespace import to gather all symbols in single object.

In latest on the time of writing TS version (3.5.2), you cannot create structures like this:

// This will NOT work (TS 3.5.2)
const TYPES = {
    Weapon: Symbol.for('Weapon')
}

Weapon will have symbol type instead of more specific typeof TYPES.Weapon, which means that it cannot be used as a key in your module class.

Debugging

You can debug resolution & registration by decorating object registrar or your own base module implementation like so:

function echanceFn(fn: any, key: string) {
    return function (...args) {
        console.log(key, 'registering')
        let result = fn(...args);
        if (typeof result == 'function') {
            return function(...args) {
                console.log(key, 'resolving');
                let r = result(...args);
                console.log(key, 'resolved', r);
                return r;
            }
        }
        return result
    };
}

function addLogging<T>(registrar: ModuleRegistrar<T>): ModuleRegistrar<T> {
    let enchanced = {} as any;
    for (let key in registrar) {
        enchanced[key] = echanceFn(t[key], key);
    }
    return enchanced;
}

class LoggableModule<T = {}> extends BaseModule<T> {
    constructor(register: ModuleRegistrar<T>) {
        super(addLogging(register));
        // this will print module class name
        console.log("Registering " + (this.constructor as any).name);
    }
}

new ContainerBuilder({ decorateRegistrar: addLogging })
    //...

By using this simple trick, you will get log entries on every module registration and every module registration and every dependency resolving.

skip key

If you need to have some properties that shouldn't be added to container, you could use skipKey property. It expected to be array of property names that should be skipped during module registration. Implementation of stateful module (Stateful module) relays internally on it. This can be useful when you need to have private properties. Since in runtime it's impossible to discern between private and public fields, you have to specify all private properties that shouldn't be registered.

Contributing

Issues & PRs are welcome! But please mind code style & tests.

License

MIT

About

Small object factory & container with compile-time type checking and lifetime management for TypeScript

Resources

Stars

Watchers

Forks

Packages

No packages published