hardwired v0.9.0
Hardwired
A minimalistic, type-safe dependency injection (DI)/inversion of control (IoC) solution for TypeScript, featuring:
- Type safety: All dependencies are checked at compile time.
- No use of decorators, reflection or static properties containing the list of dependencies.
- Designed for structural typing.
- Simplifies mocking for integration tests.
- Fully supports Node.js and browsers.
Table of Contents
Installation
This library requires TypeScript version 4.7 or higher.
Using Yarn
yarn add hardwired
Using npm
npm install hardwired
Introduction
Hardwired centers around two key concepts:
- Instance Definition: Describes how instances should be created, specifying lifespan (
singleton
,transient
,scoped
) and dependencies. - Container: Manages instance creation based on the lifetimes defined in instance definitions. Can memoize instances where applicable (e.g.,
singleton
,scoped
).
Quick Start
Define Dependencies: Create and organize definitions in separate modules to keep the implementation decoupled from IoC details.
// implementation.ts import { singleton } from 'hardwired'; export class LoggerConfiguration { logLevel = 0; } export class Logger { constructor(private configuration: LoggerConfiguration) {} log(message: string) {} } // definitions.ts export const configurationDef = singleton.class(LoggerConfiguration); export const loggerDef = singleton.using(configurationDef).class(Logger);
Create a Container: Instantiate a container to manage instances.
import { container } from 'hardwired'; const exampleContainer = container();
Retrieve Instances: Use the container to get instances as needed.
const loggerInstance: Logger = exampleContainer.get(loggerDef); // returns an instance of Logger
Lifetimes of Definitions
Definitions are categorized by their lifetimes, affecting instance creation:
transient
: Creates a new instance on each retrieval.singleton
: Always reuses a single instance.scoped
: Similar to a singleton but confined to a specific scope.
Container Scopes
Each container manages its own instance registry, allowing for scoped lifetimes.
A new scope can be created with the checkoutScope
method,
which inherits the singleton registry but starts with a clean scoped registry.
import { container, scoped, singleton } from 'hardwired';
const scopedRandomVal = scoped.fn(() => Math.random());
const singletonRandomVal = singleton.fn(() => Math.random());
const appContainer = container();
const requestContainer = appContainer.checkoutScope();
const val1 = appContainer.get(scopedRandomVal);
const val2 = requestContainer.get(scopedRandomVal);
// val1 !== val2, due to different scoped registries
const singletonVal1 = appContainer.get(singletonRandomVal);
const singletonVal2 = requestContainer.get(singletonRandomVal);
// singletonVal1 === singletonVal2, shared singleton registry
Definition Types
Definitions support the following types of instances:
- class: Creates an instance of a class.
- fn: Creates an instance using a factory function.
- define: Low-level utility that accepts a factory function that has directly access to the container allowing using it as a service locator.
Definitions can be synchronous or asynchronous, supporting both sync and async dependencies accordingly.
Synchronous Definitions
[singleton|scoped|transient].[fn|class|define]()
: These are instantiated synchronously and allow only synchronous dependencies.
class
import { singleton, container } from 'hardwired'; class Logger { info() {} } class Writer { constructor(private logger: Logger) {} } const loggerDef = singleton.class(Logger); const writerDef = singleton.using(loggerDef).class(Writer); const writerInstance = container().get(writerDef); // creates instance of Writer
fn
import { singleton, container, transient } from 'hardwired'; const aDef = transient.fn(() => 1); const bDef = transient.fn(() => 2); const cDef = singleton.using(aDef, bDef).fn((a, b) => a + b); const c = container().get(cDef); // result equals to 3
define
import { singleton, container, value, define } from 'hardwired'; const randomValD = scoped.fn(() => Math.random()); const myDef = singleton.define(container => { const val1 = container.get(randomValD); const val2 = container.withScope(childContainer => { return childContainer.get(randomValD); }); return [val1, val2]; }); const [val1, val2] = container().get(myDef); // val1 is not eq to val2, because was created in the other scope
Additionally, the library provides a helper for creating definitions for static values. Using this kind of definition is useful when the static value needs to be replaced in tests without using a test runner's mocking capabilities.
value
- defines a static valueimport { value, container } from 'hardwired'; const configDef = value({ port: 1234 }); const cnt = container(); const config = cnt.get(configDef); // { port: 1234 } cnt.get(configDef) === cnt.get(configDef); // true - returns the same instance
Asynchronous Definitions
[singleton|scoped|transient|.async().[fn|class|define]()
- These supports asynchronous dependencies and instantiation.
class
- creates class instance accepting async dependenciesimport { singleton, container } from 'hardwired'; import { Db } from 'some-db-client'; const createDbConnection = async (): Promise<Db> => { // create db connection asynchronously }; class UserRepository { constructor(private db: Db) {} findUserById(userId: string): Promise<User> { //... } } const dbDef = singleton.async().fn(createDbConnection); const userRepositoryDef = singleton.async().using(dbDef).class(UserRepository); const cnt = container(); const userRepository: UserRepository = await cnt.get(userRepositoryDef);
fn
- the same as synchronousfn
but accepts async dependenciesdefine
- the same as synchronousdefine
but accepts async functionimport { singleton, container, value, define } from 'hardwired'; const randomValD = scoped.async().fn(async () => Math.random()); const myDef = singleton.async().define(async container => { const val1 = await container.get(randomValD); const val2 = await container.withScope(childContainer => { return childContainer.get(randomValD); }); return [val1, val2]; }); const [val1, val2] = await container().get(myDef); // val1 is not eq to val2, because was created in other scope
Overriding Definitions
For integration testing or specific runtime needs, definitions can be overridden in the container, allowing for flexibility such as mocking.
import { singleton, container, set } from 'hardwired';
class RandomGenerator {
constructor(public seed: number) {}
}
const randomSeedD = singleton.fn(() => Math.random());
const randomGeneratorDef = singleton.using(randomSeedD).class(RandomGenerator);
const cnt = container([set(randomSeedD, 1)]);
const randomGenerator = cnt.get(randomGeneratorDef);
randomGenerator.seed === 1; // true
Available Overrides
set
- it replaces original definition with a new static valuereplace
- it replaces original definition with new one. This enables switching lifespan of the definitionimport { singleton, container, replace, transient } from 'hardwired'; const mySingletonDef = singleton.fn(generateUniqueId); // change lifetime for mySingletonDef to transient const mySingletonOverrideDef = replace(mySingletonDef, transient.fn(generateUniqueId)); const cnt = container([mySingletonOverrideDef]); cnt.get(mySingletonDef) === cnt.get(mySingletonDef); // false // cnt uses now transient lifetime for mySingletonDef and calls generateUniqueId on each .get call
decorate
- it takes decorator function and returns decorated objectimport { singleton, container, decorate } from 'hardwired'; interface IWriter { write(data); } class Writer implements IWriter { write(data) {} } class Logger { info(msg) {} } class LoggingWriter implements IWriter { constructor( private writer, private logger: Logger, ) {} write(data) { this.logger.info('Writing data'); this.writer.write(data); this.logger.info('Done'); } } const writerDef = singleton.class(Writer); const loggerDef = singleton.class(Logger); const writerOverrideDef = decorate( writerDef, (originalWriter, logger) => new LoggingWriter(originalWriter, logger), loggerDef, // inject extra dependency required by LoggingWriter ); const cnt = container([writerOverrideDef]); cnt.get(writerDef); // returns instance of LoggingWriter
apply
- allows triggering side effects on original instanceimport { singleton, container, apply } from 'hardwired'; class Writer { write(data) {} } class WriteManager { constructor(private writer: Writer) {} storeDocument(document) { this.writer.write(dataForDocument); } } class StoreDocumentAction { constructor(private writeManager: WriteManager) {} run() { this.writeManager.storeDocument({ someData }); } } const writerDef = singleton.class(Writer); const writeManagerDef = singleton.using(writerDef).class(WriteManager); const storeDocumentActionDef = singleton.using(writeManagerDef).class(StoreDocumentAction); const writerPatch = apply(writerDef, writerInstance => { jest.spyOn(writerInstance, 'write'); // comparing to the decorator override, there is no need to return decorated value }); const cnt = container([writerPatch]); const [spiedWriter, storeDocumentAction] = cnt.getAll(writerDef, storeDocumentActionDef); storeDocumentAction.run(); // now we can do some assertions on spied write method expect(spiedWriter.write).toHaveBeenCalledWith(/*...*/);
Override Scope
Overrides can be provided during a scope creation. Then they apply only to the current scope
import { scoped, container, set } from 'hardwired';
const def = scoped.fn(() => Math.random());
const cnt = container();
cnt.get(def); // random value
cnt.checkoutScope({ overrides: [set(def, 1)] }).get(def); // 1
Overrides can be provided also during container creation. Then the override is propagated to every child scope replacing scope's own overrides.
import { scoped, container, set } from 'hardwired';
const def = scoped.fn(() => Math.random());
const cnt = container({
globalOverrides: set(def, 100),
});
cnt.get(def); // 100
cnt.checkoutScope({ overrides: [set(def, 1)] }).get(def); // 100 because of globalOverrides
Implicit Definition
Implicit definitions act as placeholders for values provided at runtime, useful for dynamic configurations or objects that are not available at compile time.
import { external, singleton } from 'hardwired';
import http from 'http';
type EnvConfig = {
server: {
port: number;
};
};
const envD = implicit<EnvConfig>('env');
const appPortD = singleton.using(envD).fn((config: EnvConfig) => config.server.port);
const httpServerD = singleton.using(appPortD).fn((port: number) => {
const requestListener = (req, res) => {};
const server = http.createServer(requestListener);
return server.listen(port);
});
The actual value for implicit placeholder needs to be provided when creating the container.
import { container, set } from 'hardwired';
const cnt = container({ globalOverrides: [set(envD, { server: { port: 1234 } })] });
cnt.get(httpServerD);
7 days ago
13 days ago
7 months ago
8 months ago
1 year ago
1 year ago
1 year ago
2 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago