PumpIt is a small (~2.3KB) dependency injection container without the decorators and zero dependencies, suitable for the browser.
It supports different injection scopes, child containers, hooks etc...
- Motivation
- Getting Started
- Resolving container data
- Injection tokens
- Injection scopes
- Optional injections
Circular dependenciesInjecting arrays- Removing values from the container
- Replacing bindings
- Inspecting the container
- Child containers
- Error handling
- Helpers
- API docs
- License
Dependency injection is a powerful concept, and there are some excellent solutions like tsyringe, awilix, and inversify, however, they all use decorators (which are great, but not a standard), and their file size is not suitable for front-end development. So I've decided to create an implementation of a dependency injection container that is small and doesn't use decorators. I also believe that I've covered all the functionality of the above-mentioned libraries.
Installation:
npm i pumpitSince PumpIt does not rely on the decorators the injection is done via the injection property. When used with classes, inject will be a static property on the class, and it will hold an array of registered injection tokens that will be injected into the constructor in the same order when the class instance is created (in case of factory functions it will be a property on the function itself, more on that later).
import { PumpIt } from 'pumpit'
const container = new PumpIt()
const bindKeyB = 'b'
class TestA {
static inject = [bindKeyB]
constructor(b: B) {}
}
class TestB {}
// bind (register) classes to the injection container.
container.bindClass(TestA, TestA).bindClass(bindKeyB, TestB)
//resolve values
const instanceA = container.resolve(TestA) // TestA, inferred from the class key
const instanceB = container.resolve<TestB>(bindKeyB)
instanceA.b // injected B instanceWhen the key is the class,
resolveinfers the instance type for you. For string and symbol keys there is nothing to infer from, so either pass the type explicitly or use a typed token.
There is also alternative syntax that you can use when you don't want to use the static inject property, or you are importing a class from third-party packages.
import { PumpIt } from 'pumpit'
const container = new PumpIt()
class TestA {
constructor(b: TestB) {}
}
class TestB {}
//`bind`(register) class to the injection container.
container.bindClass(TestA, { value: TestA, inject: [TestB] })
//or
container.bindClass('some_key_to_bind', { value: TestA, inject: [TestB] })You can also use a special INJECT_KEY value (which is actually a Symbol) to inject the dependencies, this also helps when you can't use
a static inject property on a class (maybe the property already exists or it's a third party class)
import { PumpIt, INJECT_KEY } from 'pumpit'
const container = new PumpIt()
class TestB {}
class TestA {
static [INJECT_KEY] = [TestB]
constructor(b: TestB) {}
}
//or you can also use this
TestA[INJECT_KEY] = [TestB]
container.bindClass(TestA,TestA)
container.bindClass(TestB,TestB)Class injection inheritance is supported out of the box, which means that the child class will get dependencies that are set to be injected to the parent.
const pumpIt = new PumpIt()
class TestB {}
class TestA {
static inject = [TestB]
}
class TestC extends TestA {
//TestB will be injected by reading `inject` array from the parent (TestA)
constructor(public b: TestB) {
super()
}
}
pumpIt.bindClass(TestA, TestA)
pumpIt.bindClass(TestB, TestB)
pumpIt.bindClass(TestC, TestC)
const instance = pumpIt.resolve<TestC>(TestC)
expect(instance.b).toBeInstanceOf(TestB)Child class can define their own dependencies and combine them with the parent dependencies.
class TestB {}
class TestD {}
class TestA {
static inject = [TestB]
constructor(public b: TestB) {}
}
class TestC extends TestA {
// use dependencies from the parent and add your own (TestD class)
static inject = [...TestA.inject, TestD]
constructor(
public b: TestB,
public d: TestD
) {
super()
}
}
pumpIt.bindClass(TestA, TestA)
pumpIt.bindClass(TestB, TestB)
pumpIt.bindClass(TestC, TestC)
pumpIt.bindClass(TestD, TestD)
const instance = pumpIt.resolve<TestC>(TestC)
expect(instance.b).toBeInstanceOf(TestB)
expect(instance.d).toBeInstanceOf(TestD)When registering function factories, function needs to be provided as the value, and when that value is requested,
the function will be executed and returned result will be the value that will be injected where it is needed.
const container = new PumpIt()
const myFactory = () => 'hello world'
container.bindFactory(myFactory, myFactory)
const value: string = container.resolve(myFactory)
value === 'hello world'Factories can also have dependencies injected. They will be passed as the arguments to the factory function when it is executed.
const container = new PumpIt()
class A {
hello() {
return 'hello from A'
}
}
const myFactory = (a: A) => {
return a.hello()
}
myFactory.inject = [A]
container.bindClass(A, A)
container.bindFactory(myFactory, myFactory)
const value: string = container.resolve(myFactory) //hello from AOr alternative syntax (same as class alternative syntax):
const container = new PumpIt()
class A {
hello() {
return 'hello from A'
}
}
const myFactory = (a: A) => {
return a.hello()
}
container.bindClass(A, A)
container.bindFactory(myFactory, { value: myFactory, inject: [A] })
const value: string = container.resolve(myFactory)
value === 'hello from A'You can also use a special INJECT_KEY value (which is actually a Symbol) to inject the dependencies. This is not that much useful when
working with factories, but it can be very useful when working with classes
import { PumpIt, INJECT_KEY } from 'pumpit'
const container = new PumpIt()
class A {
hello() {
return 'hello from A'
}
}
const myFactory = (a: A) => {
return a.hello()
}
myFactory[INJECT_KEY] = [A]
container.bindClass(A, A)
container.bindFactory(myFactory, myFactory)
const value: string = container.resolve(myFactory) //hello from AI encourage you to experiment with factories because they enable you to return anything you want.
Values should be used when you just want to get back the same thing that is passed in to be registered.
const container = new PumpIt()
const myConfig = { foo: 'bar' }
container.bindValue('app_config', myConfig)
const resolvedConfig = container.resolve('app_config')
resolvedConfig === myConfigWhen the container data is resolved, if the key that is requested to be resolved is not found, the container will throw an error.
const container = new PumpIt()
container.resolve('key_does_not_exist') // will throwUse tryResolve when a missing key is an acceptable outcome. It behaves exactly
like resolve, except an unbound key gives back undefined instead of throwing.
const container = new PumpIt()
container.tryResolve('key_does_not_exist') // undefined
tryResolveonly forgives the key you asked for. If the key is bound but one of its own required dependencies is missing, it still throws - make that dependency optional if it should be tolerated.
Injection tokens are the values by which the injection container knows how to resolve registered data. They can be string, Symbol, or any object.
const container = new PumpIt()
const symbolToken = Symbol('my symbol')
class A {}
//bind to container
container.bindClass('my_token', A)
container.bindClass(symbolToken, A)
container.bindClass(A, A)
//resolve
container.resolve<A>('my_token')
container.resolve<A>(symbolToken)
container.resolve<A>(A)
//inject tokens
class B {
static inject = [symbolToken, 'my_token', A]
constructor(aOne: A, aTwo: A, aThree: A) {}
}A plain string or Symbol key carries no type information, so resolve has no
way to know what comes back and you end up annotating every call by hand - and
nothing stops you from getting it wrong.
token<T>() creates a Symbol that remembers its type. Bindings made under it
are type checked, and resolve infers the result.
import { PumpIt, token } from 'pumpit'
type Config = { url: string; retries: number }
const configToken = token<Config>('config')
const container = new PumpIt()
container.bindValue(configToken, { url: 'https://example.com', retries: 3 })
const config = container.resolve(configToken) // Config, no type argument needed
container.bindValue(configToken, 42) // compile error, 42 is not a ConfigIt works the same way for classes and factories, where the binding must produce the token's type:
const loggerToken = token<Logger>('logger')
container.bindClass(loggerToken, Logger) // ok
container.bindFactory(loggerToken, () => new Logger()) // ok
container.bindClass(loggerToken, SomethingElse) // compile errorTokens are ordinary symbols at runtime, so they can be injected like any other key:
class Service {
static inject = [configToken]
constructor(public config: Config) {}
}The description passed to
token()is only used to make error messages readable. Two tokens created with the same description are still different keys.
There are four types of injection scopes:
Once the value is resolved the value will not be changed as long as the same container is used.
In the next example, both A and B instances have the same instance of C
import { PumpIt, SCOPE } from 'pumpit'
container = new PumpIt()
class A {
static inject = [C, B]
constructor(
public c: C,
public b: B
) {}
}
class B {
static inject = [C]
constructor(public c: C) {}
}
class C {}
container.bindClass(A, A)
container.bindClass(B, B)
container.bindClass(C, C, { scope: SCOPE.SINGLETON })
// A -> B,C,
// B -> C
const instanceA = container.resolve(A)
//A and B share the same instance C
instanceA.c === instanceA.b.cThis is the default scope. Every time the value is requested, a new value will be returned (resolved).
In the case of classes, it will be a new instance every time, in the case of factories, the factory function will be executed every time.
In the next example, both A and B instances will have a different C instance.
import { PumpIt, SCOPE } from 'pumpit'
container = new PumpIt()
class A {
static inject = [C, B]
constructor(
public c: C,
public b: B
) {}
}
class B {
static inject = [C]
constructor(public c: C) {}
}
class C {}
container.bindClass(A, A)
container.bindClass(B, B)
container.bindClass(C, C, { scope: SCOPE.TRANSIENT })
// A -> B,C,
// B -> C
const instanceA = container.resolve(A)
//C instance is created two times
//A and B have different instances of C
instanceA.c !== instanceA.b.c //CThis is similar to the singleton scope except the value is resolved once per resolve request chain.
Every new call to container.resolve() will create a new value.
import { PumpIt, SCOPE } from 'pumpit'
container = new PumpIt()
class A {
static inject = [C, B]
constructor(
public c: C,
public b: B
) {}
}
class B {
static inject = [C]
constructor(public c: C) {}
}
class C {}
container.bindClass(A, A)
container.bindClass(B, B)
container.bindClass(C, C, { scope: SCOPE.REQUEST })
const firstA = container.resolve(A)
const secondA = container.resolve(A)
firstA.c === firstA.b.c // A and B share C
secondA.c === secondA.b.c // A and B share C
secondA.c !== firstA.c //C from first request is different to the C from the second requestThis scope is similar to the regular singleton scope, but in the case of child containers, the child container will create its version of the singleton instance.
In the next example, the child container will create its own version of the singleton instance.
import { PumpIt, SCOPE } from 'pumpit'
container = new PumpIt()
const childContainer = container.child()
class A {
static count = 0
constructor() {
A.count++
}
}
container.bindClass(A, A, { scope: SCOPE.CONTAINER_SINGLETON })
const parentOneA = container.resolve(A)
const parentTWoA = container.resolve(A)
parentOneA === parentTWoA
A.count === 1
const childOneA = childContainer.resolve(A)
const childTwoA = childContainer.resolve(A)
childOneA === childTwoA
A.count === 2
// parent and child have different instances
childOneA !== parentOneAInjection scopes do not apply to bound values (
bindValue)
Whenever the injection container can't resolve the requested dependency anywhere in the chain, it will immediately throw.
But you can make the dependency optional, and if it cant be resolved, the container will not throw, and undefined will be injected in place of the requested dependency. For this, you need to use the get() helper function.
import { PumpIt, get } from 'pumpit'
const container = new PumpIt()
class A {
//make B optional dependency
static inject = [get(B, { optional: true })]
constructor(public b: B) {}
}
class B {}
//NOTE: B is NOT registered with the container
container.bindClass(A, A)
const instanceA = container.resolve(A)
instanceA.b // undefinedNOTE: Circular dependency functionality has been removed in version 6. If you want to use circular dependency you can use version 5
NOTE: Injecting array as a dependency has been removed in version 6. If you want to use this feature you can use version 5
If the class that is being constructed (resolved) has a "postConstruct" method defined it will be called automatically when the class instance is created, in the case of singleton instances it will be called only once. One more important thing about postConstruct method is that it will be called in the reverse order of the resolution chain. Please refer to this test for a concrete example
Registered values can be removed from the container. When the value is removed, trying to resolve the value will throw an error.
const container = new PumpIt()
container.bindValue('name', 'Mario')
container.unbind('name')
container.resolve('name') // throws errorIf the class has a method dispose() (or a [Symbol.dispose]() method, which
takes precedence) it will automatically be called on the disposed of value, but
only if the value is a singleton.
Internally, the container will remove the value from its internal pool, and if the value was registered with the scope: singleton and the value has been resolved before (class has been instantiated or factory function executed). That means that the container holds an instance of the value, and it will try to call the dispose of method on that instance, or in the case of the factory, on whatever was returned from the factory.
const container = new PumpIt()
class TestA {
static count = 0
dispose() {
TestA.count++
}
}
pumpIt.bindClass(TestA, TestA, { scope: 'SINGLETON' })
pumpIt.unbind(TestA)
pumpIt.has(TestA) // false
TestA.count === 1If you don't want to call the dispose method, pass false as the second parameter container.unbind(TestA, false)
You can remove all the values from the container by calling container.unbindAll(). This method will remove all the keys from the container, so the container will be empty. All the same, rules apply as for the container.unbind() method.
const container = new PumpIt()
const callDispose = true
container.unbindAll(callDispose)The container itself implements Symbol.dispose, so it can be scoped with
using on runtimes that support explicit resource management. Leaving the scope
calls unbindAll(), which disposes every cached singleton.
{
using container = new PumpIt()
container.bindClass(TestA, TestA, { scope: SCOPE.SINGLETON })
container.resolve(TestA)
} // every binding is removed here, TestA instance is disposedDisposal ignores the lock. Locking guards against callers editing bindings, while
usingowns the container's whole lifetime - and throwing out of a disposal would mask whatever the enclosing block was doing.unbindAll()still refuses a locked container.
If the container is locked that particular container can't accept new bindings or unbind the values already in the container.
Locking the container does not affect child containers.
const container = new PumpIt()
class TestA {}
class TestB {}
container.bindClass(TestA, TestA)
container.lock()
container.isLocked() // returns true
container.bindClass(TestB,TestB) //throws error
container.unbind(TestA) //throws errorBinding a key that is already taken throws. Pass replace: true when that is the
point - handy in tests, where a real dependency is swapped for a fake.
const container = new PumpIt()
container.bindClass('mailer', RealMailer, { scope: SCOPE.SINGLETON })
container.resolve('mailer')
container.bindClass('mailer', FakeMailer, {
scope: SCOPE.SINGLETON,
replace: true
})
container.resolve('mailer') // FakeMailerReplacing unbinds the previous entry first, so its cached singleton is dropped and disposed. A locked container still refuses the change.
getKeys() lists everything bound on the container. Pass true to walk the
parent chain as well, where a shadowed key is reported once.
const parent = new PumpIt()
const child = parent.child()
parent.bindValue('parent_key', 1)
child.bindValue('child_key', 2)
child.getKeys() // ['child_key']
child.getKeys(true) // ['child_key', 'parent_key']Every container instance can create a child container. Or every container can set it's parent.
The child container is a new PumpIt instance that is connected to the parent container instance and it inherits all the values that are registered with the parent.
The great thing about the child container is that it can shadow the parent value by registering a value with the same key.
The child container can have the same key as the parent, in that case when the value is resolved, the child container value will be returned.
const parent = new PumpIt()
const child = parent.child()
//or child = new Pumpit()
// child.setParent(parent)
const key = 'some_key'
class ParentClass {}
class ChildClass {}
parent.bindClass(key, ParentClass)
child.bindClass(key, ChildClass)
const instance = child.resolve(key) // ChildClassParent -> child chains can be as long as you like
grand parent -> parent -> child...
When you check if the value exists on the child, the parent instance is also searched. You can optionally disable searching on the parent.
const parent = new PumpIt()
const child = parent.child()
class TestA {}
parent.bindClass(TestA, TestA)
child.has(TestA) //true
// disable search on the parent
child.has(TestA, false) // falseIf the parent container has registered a value with a scope SINGLETON all child containers will share the same instance however, if the parent has registered the value with the scope CONTAINER_SINGLETON then child containers
will create their versions of singleton instances.
const parent = new PumpIt()
const child = parent.child()
class TestA {
static count = 0
constructor() {
TestA.count++
}
}
parent.bindClass(TestA, TestA, { scope: SCOPE.CONTAINER_SINGLETON })
const parentInstance = parent.resolve<TestA>(TestA)
const childInstance = child.resolve<TestA>(TestA)
parentInstance !== childInstance
TestA.count === 2Calling validate or validateSafe will validate the bindings in the container.
It will check if all the dependencies that are required by other bindings are present in the container.
validate method will throw a PumpitValidationError, while validateSafe will always return a validation result. Calling these methods will not instantiate classes or run factory functions, so there is still a possibility that you will not get what you want when dependencies are resolved at runtime.
Dependencies declared as optional are allowed to be missing, so they are never reported. Keys bound on a parent container count as present.
In the next example RequestTest class is not present in the container, but is needed in class TestB
const pumpIt = new PumpIt()
class TestA {}
class TestB {
static inject = [TestA]
constructor(
public a: TestA,
) {}
}
//bind only TestB
pumpIt.bindClass(TestB, TestB)
const result = pumpIt.validateSafe()
expect(result).toEqual({
valid: false,
errors: [{ key: TestA, wantedBy: [TestB] }],
})Every error the container throws is a PumpitError carrying a code, so failures
can be handled without matching on message strings.
import { PumpIt, PumpitError, ERROR_CODE } from 'pumpit'
try {
container.resolve('nope')
} catch (e) {
if (e instanceof PumpitError && e.code === ERROR_CODE.KEY_NOT_FOUND) {
// ...
}
}The available codes are KEY_NOT_FOUND, KEY_ALREADY_EXISTS, CIRCULAR_REFERENCE,
CONTAINER_LOCKED, PARENT_CYCLE and VALIDATION.
Validation failures throw a PumpitValidationError, a PumpitError subclass that
also carries the full result array described in
validating bindings.
registerInjections helper function with a class or factory. It will automatically create inject property on the class or factory function.
test("use helper to inject in to class", () => {
const pumpIt = new PumpIt()
class TestA {}
class TestB {}
class TestC {
constructor(
public a: TestA,
public b: TestB,
) {}
}
registerInjections(TestC, [TestA, TestB])
pumpIt
.bindClass(TestA, TestA)
.bindClass(TestB, TestB)
.bindClass(TestC, TestC)
const result = pumpIt.resolve<TestC>(TestC)
expect(result.a).toBeInstanceOf(TestA)
expect(result.b).toBeInstanceOf(TestB)
})Call
registerInjectionsbefore binding the class or factory. Injection metadata is read once, when the value is bound, so changes made afterwards are not picked up.
PumpIt is written in TypeScript and ships its own type declarations, so the full API documentation is available directly in your editor via autocomplete and hover. No @types/* package is required.
This project is licensed under the MIT License - see the LICENSE file for details