unused-name
v0.3.0
Published
Small dependency injection implementation with no runtime dependencies
Maintainers
Readme
Unused Name DI
Tiny and powerful strictly typed dependency injection implementation for typescript. No runtime dependencies.
Featuring:
- Container scoping, container hierarchy
- Lazy service resolution, minimal unnecessary work
- Typed service resolution based on key
- Type checking when injecting services by key
- Minimal runtime checks, static checks do the work
- NO token system, use any valid object key
Getting Started
ServiceContext
To create a dependency injection ServiceContainer, a ServiceContext must be established.
A ServiceContext defines invariant key-to-type relationships that all ServiceContainer instances derived from it must adhere to when registering service implementations.
It is strongly advised that the ServiceContext is defined in a file that only imports the types of the services rather than the implementations, to help avoid circular dependency issues.
Below is an example of how to define a ServiceContext.
import * as UnusedName from "unused-name";
const context = UnusedName.context<{
DateService: DateService,
FileService: FileService,
ChatService: ChatService,
AppId: string,
PixelWidth: number,
OtherService: OtherService,
}>();A ServiceContext is also used to create injectable variants of service providers that are intended for use in the ServiceContainer instances derived from it.
If multiple ServiceContext instances plan on using the same service provider, they must all create their own injectable variant.
When creating these injectable service providers, services injected into them must be specified via their keys. This key tuple is strictly typed. Almost any attempts to specify an invalid set of service keys will be marked as a type error.
// importing the context from before
import { firstContext } from "...";
import { secondContext } from "...";
import type { ChatService } from "...";
// base service provider
class ChatServiceImpl implements ChatService {
private readonly date: DateService;
private readonly file: FileService;
/**
* constructor takes in args with types
* registered in the service context used
* for injection later
*/
constructor(date: DateService, file: FileService) {
this.date = date;
this.file = file;
}
}
// This export behaves as the registered class,
// with added metadata for use in other checks.
// It can be used in 'firstContext' containers.
export const ChatServiceFirst = firstContext.inject(ChatServiceImpl, [
"DateServiceFirst",
"FileServiceFirst",
]);
// If we wanted to use this service provider in
// 'secondContext' containers then we would need
// use this variant.
export const ChatServiceSecond = secondContext.inject(ChatServiceImpl, [
"DateServiceSecond",
"FileServiceSecond",
]);ServiceContainer
A ServiceContainer is some set of service implementations complying with the root ServiceContext. They can be created directly from their root ServiceContext, or derived from other ServiceContainer instances.
When creating a new ServiceContainer, service implementations can be specified or adjusted. The only limitations are as follows:
- Registered implementations must comply with the key-to-type relationships defined in the root
ServiceContext - Registered implementations cannot overwrite existing singleton service implementations
- Any newly registered service implementations must have all of their service dependencies already registered.
The last restriction above has the added side-effect of preventing most circular dependency situations.
If service implementations need to be altered, the child() method found on ServiceContext or ServiceContainer instances should be used to initialize a ServiceContainerBuilder.
If no service implementations need to be altered, the scope() method found on ServiceContainer instances can be used to directly instantiate another ServiceContainer with an identical set of service implementations.
import { context } from "...";
import { ChatService0 } from "...";
import { DateServiceImpl } from "...";
import { FileServiceImpl } from "...";
import { OtherServiceFactory } from "...";
const container = context
.child()
.instance("AppId", "AppIdValue", "singleton")
.instance("PixelWidth", 16, "singleton")
.ctor("DateService", DateServiceImpl, "transient")
.ctor("FileService", FileServiceImpl, "scoped")
.ctor("ChatService", ChatService0, "scoped")
.factory("OtherService", OtherServiceFactory, "singleton")
.build();
// now these services can all be instantiated via 'container'
const chat: ChatService = container.resolve("ChatService");
const appId: string = container.resolve("AppId");const child = container.child()
.ctor("DateService", ..., "singleton")
.factory("FileService", ..., "scoped")
.build();
// error caused due to attempted reregistration
// of the parent's singleton service
const invalid = child.child()
.ctor("DateService", ..., "scoped")
.build();import { rootContainer } from "...";
function doSomeRequestScoped() {
const scope = rootContainer.scope();
const scopedServiceInstance = scope.resolve("ScopedService");
...
}Scopes
Scopes in unused-name determine the relationship between service instances resolved from the same key, both within and between ServiceContainer instances.
Transient
Transient services always resolve to a new service instance.
import { rootContainer } from "...";
const rootResolved0 = rootContainer.resolve("Service");
const rootResolved1 = rootContainer.resolve("Service");
// rootResolved0 !== rootResolved1Scoped
Scoped services resolve to the same instance within a ServiceContainer, but different instances between ServiceContainer instances.
import { rootContainer } from "...";
let childContainer: ServiceContainer = rootContainer.scope();
const rootResolved0 = rootContainer.resolve("Service");
const rootResolved1 = rootContainer.resolve("Service");
// rootResolved0 === rootResolved1
const childResolved = childContainer.resolve("Service");
// rootResolved0 !== childResolvedSingleton
Singleton services resolve to the same instance in a ServiceContainer and all descendant ServiceContainer instances. An important note about singleton services is that their dependencies are resolved based on their ServiceContainer of origin. Scoped dependencies will therefore be carried across ServiceContainer boundaries.
import { rootContainer } from "...";
let childContainer: ServiceContainer = rootContainer.scope();
const rootResolved0 = rootContainer.resolve("Service");
const rootResolved1 = rootContainer.resolve("Service");
// rootResolved0 === rootResolved1
const childResolved = childContainer.resolve("Service");
// rootResolved0 === childResolved