0.0.4 • Published 4 years ago

@resynth1943/inject v0.0.4

Weekly downloads
-
License
MPL-2.0
Repository
-
Last release
4 years ago

@resynth1943/inject

The functional way to use Dependency Injection.

Introduction

Most Dependency Injection libraries in JavaScript are either using decorators which lose type safety, or use a Map and expect people to manually retrieve values inside class constructors. Why not do away with both of these flawed takes on how a DI system should work, and start again?

Inject takes a more functional approach to Dependency Injection. Classes are nowhere to be found.

If you're not familiar with the concept of Dependency Injection (commonly abbreviated to 'DI'), then you should read the following article as a point of reference:

A quick intro to Dependency Injection: what it is, and when to use it ─ Bhavya Karia

Example

You can view the source tree of the example here

Here's a quick example of how Inject works:

Your first Service

So let's start out with a simple service that logs messages to the console. We'll call it the LoggerService, as the recommended way to name services is *Service.

import { useKey, createService } from '@resynth1943/inject';
import { $Console } from './example';

function log (message: string) {
    const console = useKey($Console);
    console.log(message);
}

export const LoggerService = createService({
    log
});

Putting it all together

Pretty simple, huh? Now let's create something that uses this service as a dependency.

import { Domain, runInDomain, createKey, useKey, provide, useService } from '@resynth1943/inject';
import { LoggerService } from './logger';

export const $Console = createKey<Console>('console');
export const $Message = createKey<string>('message');

const appDomain: Domain = {
    providers: [
        provide($Console, console),
        provide($Message, 'Hello, world!')
    ]
};

runInDomain(appDomain, () => {
    const logger = useService(LoggerService);
    logger.log('hello, world!');

    const message = useKey($Message);

    if (typeof document === 'object') {
        document.body.innerHTML = message;
    } else {
        logger.log(message);
    }
});

So we've just done a few things here:

  1. Assigned some keys to name DI values.
  2. Created a domain to run this application.
  3. Grabbed the LoggerService from inside the domain.
  4. Used the service to do some fancy logging.

As you can see, Inject is actually pretty simple to use. Read on for more information about the above example, and how it works.

Glossary

  • key: The name of a dependency. This is used to get the value of a dependency from inside a domain.
  • domain: A descriptor providing instructions for how DI should work from inside a specialized execution context (a callback).
  • provider: An object that provides a value for a dependency. This is then retrieved using a key.
  • service: A container holding functions and state relevant to a specific task.

As you can see, there are two keys on every provider object: key; provide. The key key describes the key for which we are providing a value. The provide key describes the value we are providing.

Getting Started

If you want to get started quickly, I've created an example project which can be found here. You can play with this example project on CodeSandbox, if you prefer an online environment. Otherwise, execute the following commands in your shell:

$ git clone https://github.com/resynth1943/inject
$ tsc
$ node ./lib/example/example.js

Keys

In Inject, all DI values are labeled with keys. To create a key, use the following syntax:

import { createKey } from '@resynth1943/inject';

export const $Key = createKey<KeyType>('KeyDescription');

Providers

The concept of providers is crucial to Inject. A provider provides a value for a key. A provider looks like the following:

interface Provider<TKey extends Key<unknown> = Key<unknown>> {
    key: TKey;
    provide: GetKeyType<TKey>;
}

This is then passed to the providers field of the domain. When requesting the value of a key from inside the domain, the appropriate value will be yielded.

So we've just created a key that's equivalent to Symbol(DI.Key.KeyDescription) (but that's an implementation detail, don't worry too much about that).

You can then use this key in a provider map to provide a basic value.

Default values

When looking up a key, you can also provide an optional default value. This allows you to call upon a dependency that has not been explicitly declared.

Take the following example:

import { useKey } from '@resynth1943/inject';

runInDomain(domain, () => {
    const value = useKey($Key, 'your default value');
});

If $Key has not been provided by the domain, the useKey function will return 'your default value'.

Bear in mind that the default value must be the same type as the value of the Key.

Domains

Domains are crucial to Inject. Calling useKey outside of the execution context of a domain is not allowed.

A domain is essentially a descriptor for a Dependency Injection execution context. Take the following code:

import { Domain, provide } from '@resynth1943/inject';
import { $Key } from './shared/keys';

const domain: Domain = {
    providers: [
        provide($Key, 2)
    ]
}

To retrieve the value of a key inside this execution context, you simply do the following:

import { runInDomain, useKey } from '@resynth1943/inject';
import { $Key } from './shared/keys';

runInDomain(domain, () => {
    const value = useKey($Key);
    // use `value` here!
});

Declaring values

Declaring values allows you to provide a value for a Key.

To declare a value for a key when called inside a domain, simply do the following:

import { runInDomain, Domain, provide, createKey, useKey } from '@resynth1943/inject';

const $OurNumber = createKey<number>('OurNumber');

const domain: Domain = {
    providers: [
        provide($OurNumber, 2)
    ]
}

runInDomain(domain, () => {
    const value = useKey($OurNumber);
    // The above will yield 2.
});

Services

Think of a service as a manager for a specific task. Don't place too much logic in one service.

A service is a module, containing necessary functions and state to run isolated tasks.

Any object is a valid service. You can create a service like so:

import { createService } from '@resynth1943/inject';

export const LoggerService = createService({
    log,
    error,
    warn
});

As a general rule of practice, you should avoid exporting the properties of your service (log, error and warn in this example) outside of the service.

You can acquire a service like so:

import { LoggerService } from './services/logger';
const logger = useService(LoggerService);

In most circumstances, useService will be an identity function. If the domain overrides this service though, the override will be returned in place of the first argument.

Defining a Service

You define a required service in your domain, like so:

import { Domain } from '@resynth1943/inject';
const domain: Domain = {
    services: [LoggerService]
}

Internally, Inject binds all Services to Keys, and calls upon those keys to find the service in the domain registry.

Extending a Service

You can also use provideService to extend a service, like so:

import { Domain, provideService } from '@resynth1943/inject';
const domain: Domain = {
    services: [provideService(LoggerService, CoolLoggerService)]
}

When useService is called with LoggerService, CoolLoggerService will be returned. This is classical Dependency Injection, allowing you to provide stub versions of dependencies in testing.

License

This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/.