Skip to content

Instantly share code, notes, and snippets.

@mindplay-dk
Last active July 19, 2023 16:16
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mindplay-dk/64e1f07028c1749e06c4fc2fa8cea785 to your computer and use it in GitHub Desktop.
Save mindplay-dk/64e1f07028c1749e06c4fc2fa8cea785 to your computer and use it in GitHub Desktop.
Minimal DI Container in TypeScript
type Container<TServiceMap> = <Name extends keyof TServiceMap>(name: Name) => Promise<TServiceMap[Name]>;
type Factory<TServiceMap, Name extends keyof TServiceMap> = (container: Container<TServiceMap>) => Promise<TServiceMap[Name]>;
type FactoryMap<TServiceMap> = { [Name in keyof TServiceMap]: Factory<TServiceMap, Name> };
function Container<TServiceMap>(map: FactoryMap<TServiceMap>): Container<TServiceMap> {
const resolved: { [Name in keyof TServiceMap]?: Promise<TServiceMap[Name]> } = {};
return function resolve<Name extends keyof TServiceMap>(name: Name): Promise<TServiceMap[Name]> {
return resolved[name]! || (resolved[name] = map[name](resolve));
}
}
// EXAMPLE:
interface ServiceMap {
UserCache: UserCache;
UserService: UserService;
}
class UserCache { }
class UserService {
constructor(private cache: UserCache) { }
}
let container = Container<ServiceMap>({
UserCache: async $ => new UserCache(),
UserService: async $ => new UserService(await $("UserCache"))
});
container("UserService").then(service => {
console.log(service);
container("UserService").then(same_service => {
console.log(service === same_service); // true
});
});
type Container<TServiceMap> = <Name extends keyof TServiceMap>(name: Name) => TServiceMap[Name];
type Factory<TServiceMap, Name extends keyof TServiceMap> = (container: Container<TServiceMap>) => TServiceMap[Name];
type FactoryMap<TServiceMap> = { [Name in keyof TServiceMap]: Factory<TServiceMap, Name> };
function Container<TServiceMap>(map: FactoryMap<TServiceMap>): Container<TServiceMap> {
const resolved: { [Name in keyof TServiceMap]?: TServiceMap[Name] } = {};
return function resolve<Name extends keyof TServiceMap>(name: Name): TServiceMap[Name] {
return resolved[name]! || (resolved[name] = map[name](resolve));
}
}
// EXAMPLE:
interface ServiceMap {
UserCache: UserCache;
UserService: UserService;
}
class UserCache { }
class UserService {
constructor(private cache: UserCache) { }
}
let container = Container<ServiceMap>({
UserCache: $ => new UserCache(),
UserService: $ => new UserService($("UserCache"))
});
console.log(container("UserService"));
console.log(container("UserService") === container("UserService")); // true
// FRAMEWORK:
type Container<T> = {
[P in keyof T]: T[P] extends () => unknown ? ReturnType<T[P]> : never
}
type Factory<T> = T & ThisType<Container<T>>
function createContainer<T>(factory: Factory<T>): Container<T> {
const container = {} as Container<T>;
const instances = {} as { [key: string]: any };
const has = Object.prototype.hasOwnProperty.bind(instances);
for (const key in factory) {
Object.defineProperty(
container,
key,
{
get() {
if (! has(key)) {
instances[key] = (factory[key] as any).apply(container);
}
return instances[key];
},
configurable: true,
enumerable: true,
}
);
}
return container;
}
function createProvider<T>(factory: Factory<T>): typeof factory {
return factory;
}
// USE CASE:
type App = Container<typeof appProvider>;
type Messaging = Container<typeof messagingProvider>;
function showMessage(window: Messaging["window"], message: string) {
window.alert(message);
}
function showWelcomeMessage(showMessage: Messaging["showMessage"], user: string) {
showMessage(`Welcome, ${user}`);
}
function login(showWelcomeMessage: App["showWelcomeMessage"], user: string) {
showWelcomeMessage(user);
}
// EXAMPLE:
const messagingProvider = createProvider({
window() {
return window;
},
showMessage() {
return showMessage.bind(null, this.window);
},
});
const appProvider = createProvider({
...messagingProvider,
showWelcomeMessage() {
return showWelcomeMessage.bind(null, this.showMessage);
},
login() {
return login.bind(null, this.showWelcomeMessage);
}
});
const container = createContainer(appProvider);
container.login("Bob");
// FRAMEWORK:
type ReturnTypeMap<T> = {
[P in keyof T]: T[P] extends () => unknown ? ReturnType<T[P]> : never
}
type ContainerType<T> = ThisType<ReturnTypeMap<T>>
type FactoryMap<T> = T & ContainerType<T>
function createContainer<T>(factory: FactoryMap<T>): ReturnTypeMap<T> {
const container = {} as ReturnTypeMap<T>;
const instances = {} as { [key: string]: any };
const has = Object.prototype.hasOwnProperty.bind(instances);
for (const key in factory) {
Object.defineProperty(
container,
key,
{
get() {
if (! has(key)) {
instances[key] = (factory[key] as any).apply(container);
}
return instances[key];
},
configurable: true,
enumerable: true,
}
);
}
return container;
}
// USE CASE:
const $showMessage = (window: Window) => (message: string) => {
window.alert(message);
}
type showMessage = ReturnType<typeof $showMessage>;
const $showWelcomeMessage = (showMessage: showMessage) => (user: string) => {
showMessage(`Welcome, ${user}`);
}
type showWelcomeMessage = ReturnType<typeof $showWelcomeMessage>;
const $login = (showWelcomeMessage: showWelcomeMessage) => (user: string) => {
showWelcomeMessage(user);
}
type login = ReturnType<typeof $login>;
// EXAMPLE:
const container = createContainer({
window() {
return window;
},
showMessage() {
return $showMessage(this.window);
},
showWelcomeMessage() {
return $showWelcomeMessage(this.showMessage);
},
login() {
return $login(this.showWelcomeMessage);
}
});
container.login("Bob");
/*
// ----- alternative, container-first approach:
// USE CASE:
type App = typeof container;
const $showMessage = (window: App["window"]) => (message: string) => {
window.alert(message);
}
const $showWelcomeMessage = (showMessage: App["showMessage"]) => (user: string) => {
showMessage(`Welcome, ${user}`);
}
const $login = (showWelcomeMessage: App["showWelcomeMessage"]) => (user: string) => {
showWelcomeMessage(user);
}
// EXAMPLE:
const container = createContainer({
window() {
return window;
},
showMessage() {
return $showMessage(this.window);
},
showWelcomeMessage() {
return $showWelcomeMessage(this.showMessage);
},
login() {
return $login(this.showWelcomeMessage);
}
});
container.login("Bob");
*/
/*
// ----- alternative, container-first, flat functions approach:
// USE CASE:
type App = typeof container;
function showMessage(window: App["window"], message: string) {
window.alert(message);
}
function showWelcomeMessage(showMessage: App["showMessage"], user: string) {
showMessage(`Welcome, ${user}`);
}
function login(showWelcomeMessage: App["showWelcomeMessage"], user: string) {
showWelcomeMessage(user);
}
// EXAMPLE:
const container = createContainer({
window() {
return window;
},
showMessage() {
return showMessage.bind(null, this.window);
},
showWelcomeMessage() {
return showWelcomeMessage.bind(null, this.showMessage);
},
login() {
return login.bind(null, this.showWelcomeMessage);
}
});
container.login("Bob");
*/
/*
// ----- alternative, container-first, flat functions approach with modularity:
// USE CASE:
const $showMessage = (window: System["window"]) => (message: string) => {
window.alert(message);
}
const $showWelcomeMessage = (showMessage: System["showMessage"]) => (user: string) => {
showMessage(`Welcome, ${user}`);
}
const $login = (showWelcomeMessage: App["showWelcomeMessage"]) => (user: string) => {
showWelcomeMessage(user);
}
// EXAMPLE:
function createAppContainer(system = createSystemContainer(window)) {
return createContainer({
showWelcomeMessage() {
return $showWelcomeMessage(system.showMessage);
},
login() {
return $login(this.showWelcomeMessage);
}
});
}
type App = ReturnType<typeof createAppContainer>;
function createSystemContainer(window: Window) {
return createContainer({
window() {
return window;
},
showMessage() {
return $showMessage(this.window);
},
});
}
type System = ReturnType<typeof createSystemContainer>;
const app = createAppContainer();
app.login("Bob");
*/
// FRAMEWORK:
type Async<T> = {
[K in keyof T as `$${string & K}`]: Promise<T[K]>;
}
type FactoryMap<T> = {
[K in keyof T]: (f: Omit<T, K>) => T[K];
}
function createContainer<T>(factory: FactoryMap<T>): T {
const container = {} as T;
const instances = {} as { [key: string]: any };
const has = Object.prototype.hasOwnProperty.bind(instances);
for (const key in factory) {
Object.defineProperty(
container,
key,
{
get() {
if (! has(key)) {
instances[key] = factory[key](container);
}
return instances[key];
},
configurable: true,
enumerable: true,
}
);
}
return container;
}
// CASE STUDY:
class UserCache {
public getUser() {
return "bob";
}
}
class UserService {
constructor(public cache: UserCache) { }
}
interface UserServiceMap {
userCache: UserCache;
userService: UserService;
}
// CONTAINER EXAMPLE:
{
const container = createContainer<UserServiceMap>({
userCache: () => new UserCache(),
userService: ({ userCache }) => new UserService(userCache),
});
console.log(container.userService.cache === container.userCache);
console.log(container.userCache === container.userCache);
}
// ASYNC CONTAINER EXAMPLE:
(async () => {
const container = createContainer<Async<UserServiceMap>>({
$userCache: async () => new UserCache(),
$userService: async ({ $userCache }) => new UserService(await $userCache),
});
console.log((await container.$userService).cache === await container.$userCache);
console.log((await container.$userCache) === await container.$userCache);
})();
/*
// alternative, specification-first approach:
// USE CASE:
interface S {
window: Window;
login(user: string): void;
showMessage(message: string): void;
showWelcomeMessage(message: string): void;
}
const $showMessage = (window: S["window"]): S["showMessage"] => (message) => {
window.alert(message);
}
const $showWelcomeMessage = (showMessage: S["showMessage"]): S["showWelcomeMessage"] => (user) => {
showMessage(`Welcome, ${user}`);
}
const $login = (showWelcomeMessage: S["showWelcomeMessage"]): S["login"] => (user) => {
showWelcomeMessage(user);
}
// EXAMPLE:
const container = createContainer<S>({
window: () => window,
showMessage: ({ window }) => $showMessage(window),
showWelcomeMessage: ({ showMessage }) => $showWelcomeMessage(showMessage),
login: ({ showWelcomeMessage }) => $login(showWelcomeMessage),
});
container.login("Bob");
*/
// DEPENDENCIES:
class SharedCache { }
class UserService {
constructor(public cache: SharedCache) { }
}
class ProductService {
constructor(public cache: SharedCache) { }
}
class App {
constructor(public userService: UserService, public productService: ProductService) {}
}
// EXAMPLE:
function one<T>(make: () => T): () => T {
let instance: T;
return () => instance || (instance = make());
}
function bootstrap() {
const sharedCache = one(() => new SharedCache());
const userService = one(() => new UserService(sharedCache()));
const productService = one(() => new ProductService(sharedCache()));
const app = one(() => new App(userService(), productService()));
return app();
}
const app = bootstrap();
console.log(app.userService.cache === app.productService.cache); // TRUE
@mindplay-dk
Copy link
Author

There is no run-time difference between the synchronous and asynchronous versions - it's only a matter of type-hinting: the asynchronous version enforces the use of async for all dependencies.

The choice between the two is purely opinionated:

Using the async version will most likely save you a lot of refactoring - when a dependency changes from synchronous to asynchronous, this change will "spread" to it's dependents, which must also be made asynchronous, if they depend on something asynchronous.

On the other hand, you may not have a lot of asynchronous dependencies, and the use of Promises everywhere might just be unnecessary overhead. Or maybe your application is sensitive to startup timing and has to start up synchronously for some reason. In either case, you might prefer the synchronous version.

@mindplay-dk
Copy link
Author

I added no-container.ts to demonstrate another pattern I discovered, which doesn't require any sort of container at all - instead, the individual components are boxed in simple factory-functions, using the one function, so the same instance can be unboxed on demand, and thereby automatically gets instantiated when first used.

This pattern solves the exact same problem as the IOC container: that of constructing components on demand, so you don't have to think about the dependency graph too much yourself - it just sort of naturally unfolds and resolves.

I like this pattern, because it minifies better. With the IOC container, components need names (strings) and those don't easily minify - whereas the factory-functions in bootstrap can be mangled/minified as much as possible by any conventional minifier. (Also, if you're using plain JS, strings do not really refactor very well.)

One advantage to this pattern, is you don't have to the use the one function - if, for example, you wanted a new cache for every call, so different dependents get their own instances, just use a plain function to create that dependency. (If you prefer the IOC container, the same can be accomplished by registering a factory function and calling that - although, arguably, omitting a call is simpler than adding one to achieve the same thing.)

Being able to keep components private is another possible benefit. In this example, I exported only the top-level App instance and kept everything else private. If your application has multiple entry-points, you could choose to return one or more of the factory-functions themselves, leaving code outside to make the decision. (For example, if your CLI and server components have many common dependencies, you could reuse the same bootstrap function and return two different factories.)

Lastly, since this is all "just functions", this pattern is modular. You could, for example, isolate all your CLI dependencies, UI layer, or request-dependent dependencies in a web app, inside dedicated bootstrap-functions - each creating a "bounded context", from which you expose only the components that integrate in your top-level bootstrap-function. They don't need to have the same life-cycle either - for example, if your project is a web-server, you might have one bootstrap-function for long-lived components, and another for request-specific components that gets called for every request, creating a natural one-way dependency flow, where short-lived components can depend on long-lived components; but not the other way around.

In very large systems, having this kind of physical separation of contexts or sub-domains can help contributors distinguish, say, the cache in one domain from the cache in another domain, and so on.

@mindplay-dk
Copy link
Author

I wrote a longer article about the IOC pattern I settled on:

https://dev.to/mindplay/a-successful-ioc-pattern-with-functions-in-typescript-2nac

@mindplay-dk
Copy link
Author

May want to explore something like this in TypeScript as well. 🤔

@mindplay-dk
Copy link
Author

Added another example using this for context - it looks like the built-in ThisType<T> (an empty marker interface according to the manual and source-code) uses some kind of built-in compiler magic, which is somehow able to break the "no cicular types" limitation. (??)

@mindplay-dk
Copy link
Author

Added another approach using this with multiple providers. This seems promising, but I'm still exploring other modular patterns...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment