Resolution Constraints
Many times, you'll want a service to rely on a dependency, but there's always the possibility of it not existing (and you wouldn't want your service to fail if it didn't).
Or, you want to take advantage of container inheritance
and resolve a symbol in the context of the current parent's container, or restrict the
resolution process to return null
if the current container doesn't have it.
(There are more features too.)
This is where resolution constraints shine. They allow you to specify how you'd like a certain dependency to be resolved, by a set-list of pre-defined strategies.
This feature is very similar to Angular's DI decorators. In fact, resolution constraints were originally designed to mimic these features in TypeDI.
Therefore, if you're familiar with Angular, you most likely already understand resolution constraints.
Introduction
When a service requests an identifier (which can be another service or a token) as a dependency,
the Dependency Injection framework has to check its internal map for that identifier and, if found,
return an instance of that value.
If the identifier cannot be found, it checks its parent, which checks its parent (recursively)
until the chain is exhausted. An error is then thrown.
If you're a visual learner, here's a flow-chart of the process.
While this behaviour makes sense for most configurations, there are most certainly times when you'll want to modify it a little. In the below sections, we'll explore how to do that in TypeDI.
Resolution Constraint Flags
In TypeDI, the concept of constraining certain resolutions is done through specific functions which, when called, return a bit. Multiple functions can be conjoined with the Bitwise OR operator to form a bitmask, like so:
Optional() | Many() | Self()
You don't have to understand how bitmasks work to make use of resolution constraints. The functions above construct the bitmask for you, which can then be safely passed to TypeDI.
You should always make use of the above functions instead of constructing a bitmask yourself, as the signature of the mask could change at any time.
Making dependencies optional with Optional()
If your service wants a dependency, but doesn't need it, you can make use of the Optional()
flag.
If the identifier cannot be found, the value will be substituted with null
. (This would be useful if you were,
for example, building a library where some parts of the configuration might not have been set-up by the end-user.)
Adding an optinal constraint isn't always necessary: only add it if you're not 100% sure that the service you're using as a dependency will not be available at runtime.
Let's explore how we could make use of Optional
in an example service, which
requests an identifier from the container that may not exist.
import { Service, Optional } from '@freshgum/typedi';
import { APP_TOKEN } from './configuration'; // Where APP_TOKEN is a Token<string>.
@Service([
[APP_TOKEN, Optional()]
])
export class ConfigurationReaderService {
constructor (private appToken: string | null) { }
validateConfiguration () {
if (this.appToken === null) {
console.warn('An app token was not provided!');
}
}
}
If you're using an optional service, make sure you also allow the type
to be null (like above, where we used | null
)
Otherwise in usage, you may forget that the value may not exist, causing runtime errors.
In the above service, we requested APP_TOKEN
as a dependency.
In the case of our library, this would be set by the user before they start the root service.
However, if that value isn't set, we log a warning to the console.
Normally, if APP_TOKEN
wasn't present in the container, the container itself would throw an error.
Resolve via the container's parent with SkipSelf()
Much like in Angular, the SkipSelf
can be applied to individual dependencies
to tell the container to resolve them from its parent container.
This can be useful in the case of an application which makes use of container inheritance to provide a different set of tokens to services under it.
Use of SkipSelf
makes your services dependent on a certain container structure.
If you were to change that structure, resolutions may fail, leading to runtime errors.
Use it carefully.
For instance, consider the following example of an blog.
The application creates a Page
service for each page of the blog.
Each Page
service has access to the DOM_NODE
token, which:
- in the child container the
Page
is run in, is set to the DOM element containing the individual page. - in the parent container, is set to the
body
element.
Each page contains a dark mode button which, when clicked,
toggles the "dark-mode" class on the <body>
element.
import { Token } from '@freshgum/typedi';
export const DOM_NODE = new Token<HTMLElement>(`\
The current DOM node. In services for individual pages,
this will be set to the node of the page element.
In the root, this will be set to the body of the document.
`);
import { Service, SkipSelf } from '@freshgum/typedi';
import { STORAGE } from './storage.token';
@Service([
DOM_NODE,
[DOM_NODE, SkipSelf()]
])
export class PageService {
constructor (private pageNode: HTMLElement, private rootNode: HTMLElement) { }
bootstrap () {
this.pageNode.getElementById('dark-mode-button').addEventListener('click', () => {
this.rootNode.classList.toggle('dark-mode');
});
}
}
import { ContainerInstance, Service, HostContainer } from '@freshgum/typedi';
import { PageService } from './page.service';
import { route } from 'my-router'; // Placeholder for your router :-)
@Service([
HostContainer()
])
export class RootService {
constructor (private container: ContainerInstance) {
container.set({ id: DOM_NODE, value: document.body, dependencies: [ ] });
}
async renderPage (pageUrl: string) {
const childContainer = this.container.ofChild(Symbol('page'));
const { renderedElement } = await route(pageUrl);
childDontainer.set({ id: DOM_NODE, value: renderedElement, dependencies: [ ] });
childContainer.get(PageService).bootstrap();
}
}
const root = Container.get(RootService);
root.renderPage('/introduction');
(While this is a rather contrived example, it serves as a guide for how to use the constraint.)
Resolve non-recursively with Self()
The Self
constraint allows you to tell the container not to traverse up the container
parent tree until it finds a value.
This constraint is most useful when combined with Optional
.
That way, if the current container doesn't have the value, a runtime error would not occur.
If you're a visual learner, here's a flow-chart of the resolution process with Self
.
If we were to modify our flow-chart from above, the resolution for resolving identifiers marked with Self
would look like this:
Acquire multiple services with Many()
The Many
constraint is functionally equivalent to Container.getMany
.
It can also be combined with Optional
, SkipSelf
(or Self
) to further constrain resolution.
To provide an example of this, consider the following:
XXX