├── .changeset ├── README.md └── config.json ├── .github └── workflows │ └── main.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── assets └── logo-ioctopus.png ├── package-lock.json ├── package.json ├── specs ├── container.spec.ts ├── examples │ ├── Circular.ts │ ├── Classes.ts │ ├── Currying.ts │ ├── DI.ts │ ├── HigherOrderFunctions.ts │ ├── SimpleFunctions.ts │ ├── UseCase.ts │ └── types.ts ├── module.spec.ts └── scope.spec.ts ├── src ├── container.ts ├── index.ts ├── module.ts └── types.ts ├── tsconfig.json ├── vitest.config.mts └── vitest.setup.mts /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "restricted", 8 | "baseBranch": "master", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - '**' 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4.1.1 12 | - uses: actions/setup-node@v4.0.2 13 | with: 14 | node-version: 20.x 15 | cache: "npm" 16 | 17 | - name: Install dependencies 18 | run: npm ci 19 | 20 | - name: Linting 21 | run: npm run lint 22 | 23 | - name: Tests 24 | run: npm run test:coverage 25 | 26 | - name: Upload results to Codecov 27 | uses: codecov/codecov-action@v4 28 | with: 29 | token: ${{ secrets.CODECOV_TOKEN }} 30 | 31 | - name: Build 32 | run: npm run build 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | .idea/ 3 | dist/ 4 | .log 5 | coverage/ 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @evyweb/ioctopus 2 | 3 | ## 1.2.0 4 | 5 | ### Minor Changes 6 | 7 | - Add strings as key for dependency binding 8 | - Add strings as key for module binding 9 | 10 | ### Patch Changes 11 | - Update dev dependencies 12 | 13 | ## 1.1.0 14 | 15 | ### Minor Changes 16 | 17 | - Add Circular Dependency detection 18 | 19 | ## 1.0.0 20 | 21 | ### Major Changes 22 | 23 | - No breaking changes just first release 24 | 25 | ### Minor Changes 26 | 27 | - Classes can now have dependency object too 28 | 29 | ## 0.3.0 30 | 31 | ### Minor Changes 32 | 33 | - Modules support 34 | - Scopes support 35 | 36 | ## 0.2.2 37 | 38 | ### Patch Changes 39 | 40 | - Update dev dependencies 41 | 42 | ## 0.2.1 43 | 44 | ### Patch Changes 45 | 46 | - update dependencies 47 | 48 | ## 0.2.0 49 | 50 | ### Minor Changes 51 | 52 | - function dependencies can now be defined using a dependency object 53 | 54 | ## 0.1.0 55 | 56 | ### Minor Changes 57 | 58 | - higher order function have their own api now 59 | 60 | ## 0.0.1 61 | 62 | ### Patch Changes 63 | 64 | - Higher order functions were solved as simple functions 65 | 66 | ## 0.0.0 67 | 68 | ### Minor Changes 69 | 70 | - add basic implementation 71 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Evyweb 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A simple IOC container for Typescript 2 | [![NPM Version](https://img.shields.io/npm/v/%40evyweb%2Fioctopus.svg?style=flat)]() 3 | ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/evyweb/ioctopus/main.yml) 4 | [![codecov](https://codecov.io/gh/Evyweb/ioctopus/graph/badge.svg?token=A3Z8UCNHDY)](https://codecov.io/gh/Evyweb/ioctopus) 5 | 6 | ![NPM Downloads](https://img.shields.io/npm/dm/%40evyweb%2Fioctopus) 7 | [![NPM Downloads](https://img.shields.io/npm/dt/%40evyweb%2Fioctopus.svg?style=flat)]() 8 | 9 | ![logo-ioctopus.png](assets/logo-ioctopus.png) 10 | 11 | ## Introduction 12 | An IOC (Inversion of Control) container for Typescript. 13 | 14 | The idea behind is to create a simple container that can be used to register and resolve dependencies working with classes & functions but without reflect metadata. 15 | 16 | It is using simple Typescript code, so it can be used in any project without any dependency. 17 | 18 | Works also in NextJS middleware and node+edge runtimes. 19 | 20 | ## Installation 21 | ```npm i @evyweb/ioctopus``` 22 | 23 | ## How to use 24 | 25 | To use the container, you need to create a container and bind your dependencies. 26 | To do so you need to create an id for each dependency you want to register. 27 | 28 | This id that we call an "injection token" can be a string or a symbol. 29 | (Please note that you have to be consistent and use always strings for binding and resolving dependencies or always symbols, you can't mix them). 30 | 31 | Then you can bind the dependency to a value, a function, a class, a factory, a higher order function, or a curried function. 32 | 33 | ### Using symbols as injection tokens 34 | (You can skip "step a" if you prefer to use strings as injection tokens). 35 | 36 | a) Create a symbol for each dependency you want to register. It will be used to identify the dependency. 37 | 38 | ```typescript 39 | export const DI: InjectionTokens = { 40 | DEP1: Symbol('DEP1'), 41 | DEP2: Symbol('DEP2'), 42 | LOGGER: Symbol('LOGGER'), 43 | MY_SERVICE: Symbol('MY_SERVICE'), 44 | MY_USE_CASE: Symbol('MY_USE_CASE'), 45 | SIMPLE_FUNCTION: Symbol('SIMPLE_FUNCTION'), 46 | CLASS_WITH_DEPENDENCIES: Symbol('CLASS_WITH_DEPENDENCIES'), 47 | CLASS_WITHOUT_DEPENDENCIES: Symbol('CLASS_WITHOUT_DEPENDENCIES'), 48 | HIGHER_ORDER_FUNCTION_WITH_DEPENDENCIES: Symbol('HIGHER_ORDER_FUNCTION_WITH_DEPENDENCIES'), 49 | HIGHER_ORDER_FUNCTION_WITHOUT_DEPENDENCIES: Symbol('HIGHER_ORDER_FUNCTION_WITHOUT_DEPENDENCIES') 50 | } ; 51 | ``` 52 | 53 | b) Then create your container. 54 | 55 | ```typescript 56 | import { DI } from './di'; 57 | 58 | const container: Container = createContainer(); 59 | ``` 60 | 61 | ### Register the dependencies 62 | 63 | #### Primitives 64 | You can register primitives 65 | ```typescript 66 | container.bind(DI.DEP1).toValue('dependency1'); 67 | container.bind(DI.DEP2).toValue(42); 68 | 69 | // or using strings 70 | container.bind('DEP1').toValue('dependency1'); 71 | container.bind('DEP2').toValue(42); 72 | ``` 73 | 74 | #### Functions 75 | - You can register functions without dependencies 76 | ```typescript 77 | const sayHelloWorld = () => console.log('Hello World'); 78 | 79 | container.bind(DI.SIMPLE_FUNCTION).toFunction(sayHelloWorld); 80 | 81 | // or using strings 82 | container.bind('SIMPLE_FUNCTION').toFunction(sayHelloWorld); 83 | ``` 84 | 85 | #### Currying 86 | - You can register functions with dependencies using currying (1 level of currying) 87 | 88 | ```typescript 89 | const myFunction = (dep1: string, dep2: number) => (name: string) => console.log(`${dep1} ${dep2} ${name}`); 90 | 91 | container.bind(DI.CURRIED_FUNCTION_WITH_DEPENDENCIES) 92 | .toCurry(myFunction, [DI.DEP1, DI.DEP2]); 93 | 94 | // or using strings 95 | container.bind('CURRIED_FUNCTION_WITH_DEPENDENCIES') 96 | .toCurry(myFunction, ['DEP1', 'DEP2']); 97 | ``` 98 | 99 | - You can also use a dependency object 100 | 101 | ```typescript 102 | interface Dependencies { 103 | dep1: string, 104 | dep2: number 105 | } 106 | 107 | const myFunction = (dependencies: Dependencies) => (name: string) => console.log(`${dependencies.dep1} ${dependencies.dep2} ${name}`); 108 | 109 | // The dependencies will be listed in an object in the second parameter 110 | container.bind(DI.CURRIED_FUNCTION_WITH_DEPENDENCIES) 111 | .toCurry(myFunction, {dep1: DI.DEP1, dep2: DI.DEP2}); 112 | 113 | // or using strings 114 | container.bind('CURRIED_FUNCTION_WITH_DEPENDENCIES') 115 | .toCurry(myFunction, {dep1: 'DEP1', dep2: 'DEP2'}); 116 | ``` 117 | 118 | #### Higher order functions 119 | You can also register functions with dependencies by using higher order functions 120 | ```typescript 121 | const MyServiceWithDependencies = (dep1: string, dep2: number): MyServiceWithDependenciesInterface => { 122 | return { 123 | runTask: () => { 124 | // Do something with dep1 and dep2 125 | } 126 | }; 127 | }; 128 | 129 | // The dependencies will be listed in an array in the second parameter 130 | container.bind(DI.HIGHER_ORDER_FUNCTION_WITH_DEPENDENCIES) 131 | .toHigherOrderFunction(MyServiceWithDependencies, [DI.DEP1, DI.DEP2]); 132 | 133 | // or using strings 134 | container.bind('HIGHER_ORDER_FUNCTION_WITH_DEPENDENCIES') 135 | .toHigherOrderFunction(MyServiceWithDependencies, ['DEP1', 'DEP2']); 136 | ``` 137 | 138 | But if you prefer, you can also use a dependency object 139 | 140 | ```typescript 141 | interface Dependencies { 142 | dep1: string, 143 | dep2: number 144 | } 145 | 146 | const MyService = (dependencies: Dependencies): MyServiceInterface => { 147 | return { 148 | runTask: () => { 149 | // Do something with dependencies.dep1 and dependencies.dep2 150 | } 151 | }; 152 | }; 153 | 154 | // The dependencies will be listed in an object in the second parameter 155 | container.bind(DI.HIGHER_ORDER_FUNCTION_WITH_DEPENDENCIES) 156 | .toHigherOrderFunction(MyService, {dep1: DI.DEP1, dep2: DI.DEP2}); 157 | 158 | // or using strings 159 | container.bind('HIGHER_ORDER_FUNCTION_WITH_DEPENDENCIES') 160 | .toHigherOrderFunction(MyService, {dep1: 'DEP1', dep2: 'DEP2'}); 161 | ``` 162 | 163 | #### Factories 164 | For more complex cases, you can register factories. 165 | 166 | ```typescript 167 | container.bind(DI.MY_USE_CASE).toFactory(() => { 168 | // Do something before creating the instance 169 | 170 | // Then return the instance 171 | return MyUseCase({ 172 | myService: container.get(DI.MY_SERVICE) 173 | }); 174 | }); 175 | 176 | // or using strings 177 | container.bind('MY_USE_CASE').toFactory(() => { 178 | // Do something before creating the instance 179 | 180 | // Then return the instance 181 | return MyUseCase({ 182 | myService: container.get('MY_SERVICE') 183 | }); 184 | }); 185 | ``` 186 | 187 | #### Classes 188 | You can register classes, the dependencies of the class will be resolved and injected in the constructor 189 | 190 | ```typescript 191 | class MyServiceClass implements MyServiceClassInterface { 192 | constructor( 193 | private readonly dep1: string, 194 | private readonly dep2: number, 195 | ) {} 196 | 197 | runTask(): string { 198 | return `Executing with dep1: ${this.dep1} and dep2: ${this.dep2}`; 199 | } 200 | } 201 | 202 | container.bind(DI.CLASS_WITH_DEPENDENCIES).toClass(MyServiceClass, [DI.DEP1, DI.DEP2]); 203 | 204 | // or using strings 205 | container.bind('CLASS_WITH_DEPENDENCIES').toClass(MyServiceClass, ['DEP1', 'DEP2']); 206 | ``` 207 | 208 | But if you prefer, you can also use a dependency object: 209 | ```typescript 210 | 211 | interface Dependencies { 212 | dep1: string, 213 | dep2: number 214 | } 215 | 216 | class MyServiceClass implements MyServiceClassInterface { 217 | constructor(private readonly dependencies: Dependencies) {} 218 | 219 | runTask(): string { 220 | return `Executing with dep1: ${this.dependencies.dep1} and dep2: ${this.dependencies.dep2}`; 221 | } 222 | } 223 | 224 | container.bind(DI.CLASS_WITH_DEPENDENCIES).toClass(MyServiceClass, {dep1: DI.DEP1, dep2: DI.DEP2}); 225 | 226 | // or using strings 227 | container.bind('CLASS_WITH_DEPENDENCIES').toClass(MyServiceClass, {dep1: 'DEP1', dep2: 'DEP2'}); 228 | ``` 229 | 230 | - You can register classes without dependencies: 231 | ```typescript 232 | class MyServiceClassWithoutDependencies implements MyServiceClassInterface { 233 | runTask(): string { 234 | return `Executing without dependencies`; 235 | } 236 | } 237 | 238 | container.bind(DI.CLASS_WITHOUT_DEPENDENCIES).toClass(MyServiceClassWithoutDependencies); 239 | 240 | // or using strings 241 | container.bind('CLASS_WITHOUT_DEPENDENCIES').toClass(MyServiceClassWithoutDependencies); 242 | ``` 243 | 244 | ### Resolve the dependencies 245 | 246 | You can now resolve the dependencies using the get method of the container. 247 | 248 | ```typescript 249 | import { DI } from './di'; 250 | 251 | // Primitive 252 | const dep1 = container.get(DI.DEP1); // 'dependency1' 253 | const dep2 = container.get(DI.DEP2); // 42 254 | // or using strings 255 | const dep1 = container.get('DEP1'); // 'dependency1' 256 | const dep2 = container.get('DEP2'); // 42 257 | 258 | // Higher order function and class 259 | const myUseCase = container.get(DI.MY_USE_CASE); 260 | // or using strings 261 | const myUseCase = container.get('MY_USE_CASE'); 262 | myUseCase.execute(); 263 | 264 | // Simple function 265 | const simpleFunction = container.get(DI.SIMPLE_FUNCTION); 266 | // or using strings 267 | const simpleFunction = container.get('SIMPLE_FUNCTION'); 268 | simpleFunction('Hello World'); 269 | 270 | // Curried function 271 | const callMe = container.get(DI.CURRIED_FUNCTION_WITH_DEPENDENCIES); 272 | // or using strings 273 | const callMe = container.get('CURRIED_FUNCTION_WITH_DEPENDENCIES'); 274 | callMe('John Doe'); 275 | ``` 276 | 277 | ### Modules 278 | 279 | You can also use modules to organize your dependencies. 280 | 281 | #### Loading modules 282 | 283 | Modules can then be loaded in your container. 284 | By default, when you create a container, it is using a default module under the hood. 285 | 286 | ```typescript 287 | const module1 = createModule(); 288 | module1.bind(DI.DEP1).toValue('dependency1'); 289 | 290 | const module2 = createModule(); 291 | module2.bind(DI.DEP2).toValue(42); 292 | 293 | const module3 = createModule(); 294 | module3.bind(DI.MY_SERVICE).toHigherOrderFunction(MyService, {dep1: DI.DEP1, dep2: DI.DEP2}); 295 | 296 | const container = createContainer(); 297 | container.load(Symbol('module1'), module1); 298 | container.load(Symbol('module2'), module2); 299 | container.load(Symbol('module3'), module3); 300 | 301 | const myService = container.get(DI.MY_SERVICE); 302 | ``` 303 | The dependencies do not need to be registered in the same module as the one that is using them. 304 | Note that the module name used as a key can be a symbol or a string. 305 | 306 | #### Modules override 307 | 308 | You can also override dependencies of a module. The dependencies of the module will be overridden by the dependencies of the last loaded module. 309 | 310 | ```typescript 311 | const module1 = createModule(); 312 | module1.bind(DI.DEP1).toValue('OLD dependency1'); 313 | module1.bind(DI.MY_SERVICE).toFunction(sayHelloWorld); 314 | 315 | const module2 = createModule(); 316 | module2.bind(DI.DEP1).toValue('NEW dependency1'); 317 | 318 | const module3 = createModule(); 319 | module3.bind(DI.MY_SERVICE).toHigherOrderFunction(MyService, {dep1: DI.DEP1, dep2: DI.DEP2}); 320 | 321 | const container = createContainer(); 322 | container.bind(DI.DEP2).toValue(42); // Default module 323 | container.load(Symbol('module1'), module1); 324 | container.load(Symbol('module2'), module2); 325 | container.load(Symbol('module3'), module3); 326 | 327 | // The dependency DI.MY_SERVICE will be resolved with the higher order function and dep1 will be 'NEW dependency1' 328 | const myService = container.get(DI.MY_SERVICE); 329 | ``` 330 | 331 | #### Unload modules 332 | 333 | You can also unload a module from the container. The dependencies of the module will be removed from the container. 334 | Already cached instances will be removed to keep consistency and avoid potential errors. 335 | 336 | ```typescript 337 | const module1 = createModule(); 338 | module1.bind(DI.DEP1).toValue('dependency1'); 339 | 340 | const container = createContainer(); 341 | container.load(Symbol('module1'), module1); 342 | 343 | container.unload(Symbol('module1')); 344 | 345 | // Will throw an error as the dependency is not registered anymore 346 | const myService = container.get(DI.DEP1); 347 | ``` 348 | ### Using scopes 349 | 350 | #### Singleton scope (default) 351 | 352 | In singleton scope, the container returns the same instance every time a dependency is resolved. 353 | 354 | ```typescript 355 | container.bind(DI.MY_SERVICE).toClass(MyServiceClass, [DI.DEP1, DI.DEP2]); 356 | // or 357 | container.bind(DI.MY_SERVICE).toClass(MyServiceClass, [DI.DEP1, DI.DEP2], 'singleton'); 358 | 359 | const instance1 = container.get(DI.MY_SERVICE); 360 | const instance2 = container.get(DI.MY_SERVICE); 361 | 362 | console.log(instance1 === instance2); // true 363 | ``` 364 | #### Transient scope 365 | 366 | In transient scope, the container returns a new instance every time the dependency is resolved. 367 | 368 | ```typescript 369 | container.bind(DI.MY_SERVICE).toClass(MyServiceClass, [DI.DEP1, DI.DEP2], 'transient'); 370 | 371 | const instance1 = container.get(DI.MY_SERVICE); 372 | const instance2 = container.get(DI.MY_SERVICE); 373 | 374 | console.log(instance1 === instance2); // false 375 | ``` 376 | 377 | #### Scoped Scope 378 | In scoped scope, the container returns the same instance within a scope. Different scopes will have different instances. 379 | 380 | To use the scoped scope, you need to create a scope using runInScope. 381 | 382 | ```typescript 383 | container.bind(DI.MY_SERVICE).toClass(MyServiceClass, [DI.DEP1, DI.DEP2], 'scoped'); 384 | const instance1 = undefined; 385 | const instance2 = undefined; 386 | 387 | container.runInScope(() => { 388 | instance1 = container.get(DI.MY_SERVICE); 389 | instance2 = container.get(DI.MY_SERVICE); 390 | 391 | console.log(instance1 === instance2); // true 392 | }); 393 | 394 | container.runInScope(() => { 395 | const instance3 = container.get(DI.MY_SERVICE); 396 | 397 | console.log(instance3 === instance1); // false 398 | }); 399 | ``` 400 | 401 | Note: If you try to resolve a scoped dependency outside a scope, an error will be thrown. 402 | 403 | ### Circular dependencies 404 | 405 | IOctopus can detect circular dependencies. 406 | An error will be thrown if a circular dependency is detected. 407 | 408 | ```typescript 409 | const container = createContainer(); 410 | 411 | const A_TOKEN = Symbol('A'); 412 | const B_TOKEN = Symbol('B'); 413 | 414 | class A { 415 | constructor(public b: B) {} 416 | } 417 | 418 | class B { 419 | constructor(public a: A) {} 420 | } 421 | 422 | container.bind(A_TOKEN).toClass(A, [B_TOKEN]); 423 | container.bind(B_TOKEN).toClass(B, [A_TOKEN]); 424 | 425 | container.get(A_TOKEN); // Will throw: "Circular dependency detected: Symbol(A) -> Symbol(B) -> Symbol(A)" 426 | ``` 427 | 428 | This way you can avoid infinite loops and stack overflow errors. 429 | -------------------------------------------------------------------------------- /assets/logo-ioctopus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Evyweb/ioctopus/c69cd6bd9bdcf0f4808551a1b89d2b6c93796901/assets/logo-ioctopus.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@evyweb/ioctopus", 3 | "version": "1.2.0", 4 | "description": "A simple IoC container for JavaScript and TypeScript for classes and functions.", 5 | "main": "dist/index.js", 6 | "module": "dist/index.mjs", 7 | "types": "dist/index.d.ts", 8 | "files": [ 9 | "dist/" 10 | ], 11 | "scripts": { 12 | "build": "tsup src/index.ts --format cjs,esm --dts", 13 | "lint": "tsc --noEmit", 14 | "test": "vitest run", 15 | "test:coverage": "vitest run --coverage", 16 | "changeset": "npx changeset", 17 | "changeset:version": "npx changeset version", 18 | "publish:package": "npm run build && npx changeset publish" 19 | }, 20 | "keywords": [ 21 | "ioc", 22 | "inversion of control", 23 | "dependency injection", 24 | "dependency inversion", 25 | "typescript", 26 | "inversify", 27 | "typescript-ioc", 28 | "tsyringe", 29 | "di" 30 | ], 31 | "author": "Evyweb", 32 | "license": "MIT", 33 | "repository": { 34 | "type": "git", 35 | "url": "git+https://github.com/Evyweb/ioctopus.git" 36 | }, 37 | "publishConfig": { 38 | "access": "public" 39 | }, 40 | "devDependencies": { 41 | "@changesets/cli": "^2.27.10", 42 | "@types/node": "^22.10.1", 43 | "@vitest/coverage-v8": "^2.1.8", 44 | "jest-extended": "^4.0.2", 45 | "ts-node": "^10.9.2", 46 | "tsup": "^8.3.5", 47 | "typescript": "^5.7.2", 48 | "vitest": "^2.1.8", 49 | "vitest-mock-extended": "^2.0.2" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /specs/container.spec.ts: -------------------------------------------------------------------------------- 1 | import {DI} from "./examples/DI"; 2 | import {Container, createContainer} from "../src"; 3 | import {mock, MockProxy} from "vitest-mock-extended"; 4 | import { 5 | MyServiceClass, 6 | MyServiceClassWithDependencyObject, 7 | MyServiceClassWithoutDependencies 8 | } from "./examples/Classes"; 9 | import { 10 | CurriedFunctionWithDependencies, 11 | CurriedFunctionWithoutDependencies, 12 | LoggerInterface, 13 | MyServiceClassInterface, 14 | MyServiceInterface, 15 | MyUseCaseInterface, 16 | SayHelloType, 17 | ServiceWithoutDependencyInterface 18 | } from "./examples/types"; 19 | import { 20 | curriedFunctionWithDependencies, 21 | curriedFunctionWithDependencyObject, 22 | curriedFunctionWithoutDependencies 23 | } from "./examples/Currying"; 24 | import { 25 | HigherOrderFunctionWithDependencies, 26 | HigherOrderFunctionWithDependencyObject, 27 | HigherOrderFunctionWithoutDependency 28 | } from "./examples/HigherOrderFunctions"; 29 | import {sayHelloWorld} from "./examples/SimpleFunctions"; 30 | import {ClassA, ClassB} from "./examples/Circular"; 31 | import {UseCase} from "./examples/UseCase"; 32 | 33 | describe('Container', () => { 34 | 35 | let container: Container; 36 | 37 | beforeEach(() => { 38 | container = createContainer(); 39 | }); 40 | 41 | describe.each([ 42 | [{ key: DI.DEP1, value: 'dependency1' }], 43 | [{ key: 'DEP1', value: 'dependency1' }], 44 | ])('toValue()', ({key, value}) => { 45 | it(`should return the associated value of key: ${key.toString()}`, () => { 46 | // Arrange 47 | container.bind(key).toValue(value); 48 | 49 | // Act 50 | const result = container.get(key); 51 | 52 | // Assert 53 | expect(result).toBe(value); 54 | }); 55 | }); 56 | 57 | describe('toFunction()', () => { 58 | it('should return the associated function', () => { 59 | // Arrange 60 | container.bind(DI.SIMPLE_FUNCTION).toFunction(sayHelloWorld); 61 | 62 | // Act 63 | const sayHello = container.get(DI.SIMPLE_FUNCTION); 64 | 65 | // Assert 66 | expect(sayHello()).toBe('hello world'); 67 | }); 68 | }); 69 | 70 | describe('toHigherOrderFunction()', () => { 71 | describe('When the higher order function has dependencies', () => { 72 | beforeEach(() => { 73 | container.bind(DI.DEP1).toValue('dependency1'); 74 | container.bind(DI.DEP2).toValue(42); 75 | }); 76 | 77 | describe('When the dependencies are defined in an array', () => { 78 | it('should return the function with all its dependencies resolved', () => { 79 | // Arrange 80 | container.bind(DI.HIGHER_ORDER_FUNCTION_WITH_DEPENDENCIES) 81 | .toHigherOrderFunction(HigherOrderFunctionWithDependencies, [DI.DEP1, DI.DEP2]); 82 | 83 | // Act 84 | const myService = container.get(DI.HIGHER_ORDER_FUNCTION_WITH_DEPENDENCIES); 85 | 86 | // Assert 87 | expect(myService.runTask()).toBe('Executing with dep1: dependency1 and dep2: 42'); 88 | }); 89 | }); 90 | 91 | describe('When the dependencies are defined in an object', () => { 92 | it('should return the function with all its dependencies resolved', () => { 93 | // Arrange 94 | container.bind(DI.MY_SERVICE) 95 | .toHigherOrderFunction(HigherOrderFunctionWithDependencyObject, { 96 | dep1: DI.DEP1, 97 | dep2: DI.DEP2 98 | }); 99 | 100 | // Act 101 | const myService = container.get(DI.MY_SERVICE); 102 | 103 | // Assert 104 | expect(myService.runTask()).toBe('Executing with dep1: dependency1 and dep2: 42'); 105 | }); 106 | }); 107 | 108 | describe('When the dependencies are defined in an other format', () => { 109 | it('should throw an error', () => { 110 | // Act 111 | const expectCall = expect(() => { 112 | container.bind(DI.HIGHER_ORDER_FUNCTION_WITH_DEPENDENCIES) 113 | .toHigherOrderFunction(HigherOrderFunctionWithDependencies, 'invalid' as any) 114 | }); 115 | 116 | // Assert 117 | expectCall.toThrowError('Invalid dependencies type'); 118 | }); 119 | }); 120 | }); 121 | 122 | describe.each([ 123 | {dependencies: undefined}, 124 | {dependencies: []}, 125 | {dependencies: {}}, 126 | ])('When the higher order function has no dependencies', ({dependencies}) => { 127 | it('should just return the function', () => { 128 | // Arrange 129 | container.bind(DI.DEP1).toValue('dependency1'); 130 | container.bind(DI.HIGHER_ORDER_FUNCTION_WITHOUT_DEPENDENCIES) 131 | .toHigherOrderFunction(HigherOrderFunctionWithoutDependency, dependencies); 132 | 133 | // Act 134 | const myService = container.get(DI.HIGHER_ORDER_FUNCTION_WITHOUT_DEPENDENCIES); 135 | 136 | // Assert 137 | expect(myService.run()).toBe('OtherService'); 138 | }); 139 | }); 140 | }); 141 | 142 | describe('toCurry()', () => { 143 | describe('When the function has dependencies', () => { 144 | beforeEach(() => { 145 | container.bind(DI.DEP1).toValue('dependency1'); 146 | container.bind(DI.DEP2).toValue(42); 147 | }); 148 | 149 | describe('When the dependencies are defined in an array', () => { 150 | it('should return the function with all its dependencies resolved', () => { 151 | // Arrange 152 | container.bind(DI.CURRIED_FUNCTION_WITH_DEPENDENCIES) 153 | .toCurry(curriedFunctionWithDependencies, [DI.DEP1]); 154 | 155 | // Act 156 | const myService = container.get(DI.CURRIED_FUNCTION_WITH_DEPENDENCIES); 157 | 158 | // Assert 159 | expect(myService('curry')).toBe('Hello curry with dependency1'); 160 | }); 161 | }); 162 | 163 | describe('When the dependencies are defined in an object', () => { 164 | it('should return the function with all its dependencies resolved', () => { 165 | // Arrange 166 | container.bind(DI.CURRIED_FUNCTION_WITH_DEPENDENCIES_OBJECT) 167 | .toCurry(curriedFunctionWithDependencyObject, {dep1: DI.DEP1, dep2: DI.DEP2}); 168 | 169 | // Act 170 | const myService = container.get(DI.CURRIED_FUNCTION_WITH_DEPENDENCIES_OBJECT); 171 | 172 | // Assert 173 | expect(myService('curry')).toBe('Hello curry with dependency1 and 42'); 174 | }); 175 | }); 176 | 177 | describe('When the dependencies are defined in an other format', () => { 178 | it('should throw an error', () => { 179 | // Act 180 | const expectCall = expect(() => container.bind(DI.CURRIED_FUNCTION_WITH_DEPENDENCIES) 181 | .toCurry(curriedFunctionWithoutDependencies, 'invalid' as any)); 182 | 183 | // Assert 184 | expectCall.toThrowError('Invalid dependencies type'); 185 | }); 186 | }); 187 | }); 188 | 189 | describe.each([ 190 | {dependencies: undefined}, 191 | {dependencies: []}, 192 | {dependencies: {}}, 193 | ])('When the curried function has no dependencies', ({dependencies}) => { 194 | it('should just return the function', () => { 195 | // Arrange 196 | container.bind(DI.DEP1).toValue('dependency1'); 197 | container.bind(DI.CURRIED_FUNCTION_WITHOUT_DEPENDENCIES) 198 | .toCurry(curriedFunctionWithoutDependencies, dependencies); 199 | 200 | // Act 201 | const myService = container.get(DI.CURRIED_FUNCTION_WITHOUT_DEPENDENCIES); 202 | 203 | // Assert 204 | expect(myService()).toBe('OtherService'); 205 | }); 206 | }); 207 | }); 208 | 209 | describe('toFactory()', () => { 210 | it('should resolve all its dependencies', () => { 211 | // Arrange 212 | container.bind(DI.DEP1).toValue('dependency1'); 213 | container.bind(DI.DEP2).toValue(42); 214 | 215 | container.bind(DI.MY_SERVICE).toFactory(() => { 216 | return HigherOrderFunctionWithDependencyObject({ 217 | dep1: container.get(DI.DEP1), 218 | dep2: container.get(DI.DEP2) 219 | }); 220 | }); 221 | 222 | // Act 223 | const myService = container.get(DI.MY_SERVICE); 224 | 225 | // Assert 226 | expect(myService.runTask()).toBe('Executing with dep1: dependency1 and dep2: 42'); 227 | }); 228 | 229 | describe('When the dependency has dependencies', () => { 230 | it('should return the dependency with all its dependencies resolved', () => { 231 | // Arrange 232 | container.bind(DI.DEP1).toValue('dependency1'); 233 | container.bind(DI.DEP2).toValue(42); 234 | container.bind(DI.SIMPLE_FUNCTION).toFunction(sayHelloWorld); 235 | 236 | container.bind(DI.MY_SERVICE).toFactory(() => { 237 | return HigherOrderFunctionWithDependencyObject({ 238 | dep1: container.get(DI.DEP1), 239 | dep2: container.get(DI.DEP2) 240 | }); 241 | }); 242 | 243 | container.bind(DI.LOGGER).toValue(mock()); 244 | 245 | container.bind(DI.MY_USE_CASE).toFactory(() => { 246 | return UseCase({ 247 | myService: container.get(DI.MY_SERVICE), 248 | logger: container.get(DI.LOGGER), 249 | sayHello: container.get(DI.SIMPLE_FUNCTION) 250 | }); 251 | }); 252 | 253 | // Act 254 | const myUseCase = container.get(DI.MY_USE_CASE); 255 | 256 | // Assert 257 | expect(myUseCase.execute()).toBe('Executing with dep1: dependency1 and dep2: 42'); 258 | 259 | const fakeLogger = container.get>(DI.LOGGER); 260 | expect(fakeLogger.log).toHaveBeenCalledTimes(2); 261 | expect(fakeLogger.log).toHaveBeenCalledWith('Executing with dep1: dependency1 and dep2: 42'); 262 | expect(fakeLogger.log).toHaveBeenCalledWith('hello world'); 263 | }); 264 | }); 265 | }); 266 | 267 | describe('toClass()', () => { 268 | describe('When the class has dependencies', () => { 269 | describe('When the dependencies are defined in an array', () => { 270 | it('should return the instance with the resolved dependencies', () => { 271 | // Arrange 272 | container.bind(DI.DEP1).toValue('dependency1'); 273 | container.bind(DI.DEP2).toValue(42); 274 | container.bind(DI.CLASS_WITH_DEPENDENCIES).toClass(MyServiceClass, [DI.DEP1, DI.DEP2]); 275 | 276 | // Act 277 | const myService = container.get(DI.CLASS_WITH_DEPENDENCIES); 278 | 279 | // Assert 280 | expect(myService.runTask()).toBe('Executing with dep1: dependency1 and dep2: 42'); 281 | }); 282 | }); 283 | 284 | describe('When the dependencies are defined in an object', () => { 285 | it('should return the instance with the resolved dependencies', () => { 286 | // Arrange 287 | container.bind(DI.DEP1).toValue('dependency1'); 288 | container.bind(DI.DEP2).toValue(42); 289 | container.bind(DI.CLASS_WITH_DEPENDENCIES).toClass(MyServiceClassWithDependencyObject, { 290 | dep1: DI.DEP1, 291 | dep2: DI.DEP2 292 | }); 293 | 294 | // Act 295 | const myService = container.get(DI.CLASS_WITH_DEPENDENCIES); 296 | 297 | // Assert 298 | expect(myService.runTask()).toBe('Executing with dep1: dependency1 and dep2: 42'); 299 | }); 300 | }); 301 | 302 | describe('When the dependencies are defined in an other format', () => { 303 | it('should throw an error', () => { 304 | // Arrange 305 | container.bind(DI.DEP1).toValue('dependency1'); 306 | container.bind(DI.DEP2).toValue(42); 307 | 308 | // Act 309 | const expectCall = expect(() => { 310 | container.bind(DI.CLASS_WITH_DEPENDENCIES) 311 | .toClass(MyServiceClassWithDependencyObject, 'invalid' as any); 312 | }); 313 | 314 | // Assert 315 | expectCall.toThrowError('Invalid dependencies type'); 316 | }); 317 | }); 318 | }); 319 | 320 | describe('When the class has no dependency', () => { 321 | it('should just return the instance', () => { 322 | // Arrange 323 | container.bind(DI.CLASS_WITHOUT_DEPENDENCIES).toClass(MyServiceClassWithoutDependencies); 324 | 325 | // Act 326 | const myService = container.get(DI.CLASS_WITHOUT_DEPENDENCIES); 327 | 328 | // Assert 329 | expect(myService.runTask()).toBe('Executing without dependencies'); 330 | }); 331 | }); 332 | }); 333 | 334 | describe('When a dependency is missing', () => { 335 | it('should throw an error', () => { 336 | // Act 337 | const expectCall = expect(() => container.get(DI.NOT_REGISTERED_VALUE)); 338 | 339 | // Assert 340 | expectCall.toThrowError(`No binding found for key: ${DI.NOT_REGISTERED_VALUE.toString()}`); 341 | }); 342 | }); 343 | 344 | describe('When a circular dependency is detected', () => { 345 | it('should throw an error', () => { 346 | // Arrange 347 | const container = createContainer(); 348 | 349 | container.bind(DI.CIRCULAR_A).toClass(ClassA, [DI.CIRCULAR_B]); 350 | container.bind(DI.CIRCULAR_B).toClass(ClassB, [DI.CIRCULAR_A]); 351 | 352 | // Act 353 | const expectCall = expect(() => container.get(DI.CIRCULAR_A)); 354 | 355 | // Assert 356 | expectCall.toThrowError(/Circular dependency detected: Symbol\(CIRCULAR_A\) -> Symbol\(CIRCULAR_B\) -> Symbol\(CIRCULAR_A\)/); 357 | }); 358 | }); 359 | }); 360 | -------------------------------------------------------------------------------- /specs/examples/Circular.ts: -------------------------------------------------------------------------------- 1 | export class ClassA { 2 | constructor(public b: ClassB) { 3 | } 4 | } 5 | 6 | export class ClassB { 7 | constructor(public a: ClassA) { 8 | } 9 | } -------------------------------------------------------------------------------- /specs/examples/Classes.ts: -------------------------------------------------------------------------------- 1 | import {Dependencies, MyServiceClassInterface} from "./types"; 2 | 3 | export class MyServiceClassWithoutDependencies implements MyServiceClassInterface { 4 | runTask(): string { 5 | return `Executing without dependencies`; 6 | } 7 | } 8 | 9 | export class MyServiceClass implements MyServiceClassInterface { 10 | constructor( 11 | private readonly dep1: string, 12 | private readonly dep2: number, 13 | ) { 14 | } 15 | 16 | runTask(): string { 17 | return `Executing with dep1: ${this.dep1} and dep2: ${this.dep2}`; 18 | } 19 | } 20 | 21 | export class MyServiceClassWithDependencyObject implements MyServiceClassInterface { 22 | constructor(private readonly dependencies: Dependencies) { 23 | } 24 | 25 | runTask(): string { 26 | return `Executing with dep1: ${this.dependencies.dep1} and dep2: ${this.dependencies.dep2}`; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /specs/examples/Currying.ts: -------------------------------------------------------------------------------- 1 | import {CurriedFunctionWithDependencies, CurriedFunctionWithoutDependencies} from "./types"; 2 | 3 | export const curriedFunctionWithoutDependencies = (): CurriedFunctionWithoutDependencies => () => 'OtherService'; 4 | 5 | export const curriedFunctionWithDependencies = 6 | (dep1: string): CurriedFunctionWithDependencies => (name: string) => `Hello ${name} with ${dep1}`; 7 | 8 | export const curriedFunctionWithDependencyObject = 9 | ({dep1, dep2}: { 10 | dep1: string, 11 | dep2: string 12 | }): CurriedFunctionWithDependencies => (name: string) => `Hello ${name} with ${dep1} and ${dep2}`; 13 | 14 | -------------------------------------------------------------------------------- /specs/examples/DI.ts: -------------------------------------------------------------------------------- 1 | import {InjectionTokens} from "../../src"; 2 | 3 | export const DI: InjectionTokens = { 4 | DEP1: Symbol('DEP1'), 5 | DEP2: Symbol('DEP2'), 6 | LOGGER: Symbol('LOGGER'), 7 | MY_SERVICE: Symbol('MY_SERVICE'), 8 | MY_USE_CASE: Symbol('MY_USE_CASE'), 9 | SIMPLE_FUNCTION: Symbol('SIMPLE_FUNCTION'), 10 | NOT_REGISTERED_VALUE: Symbol('NOT_REGISTERED_VALUE'), 11 | CLASS_WITH_DEPENDENCIES: Symbol('CLASS_WITH_DEPENDENCIES'), 12 | CLASS_WITHOUT_DEPENDENCIES: Symbol('CLASS_WITHOUT_DEPENDENCIES'), 13 | HIGHER_ORDER_FUNCTION_WITH_DEPENDENCIES: Symbol('HIGHER_ORDER_FUNCTION_WITH_DEPENDENCIES'), 14 | HIGHER_ORDER_FUNCTION_WITHOUT_DEPENDENCIES: Symbol('HIGHER_ORDER_FUNCTION_WITHOUT_DEPENDENCIES'), 15 | CURRIED_FUNCTION_WITHOUT_DEPENDENCIES: Symbol('CURRIED_FUNCTION_WITHOUT_DEPENDENCIES'), 16 | CURRIED_FUNCTION_WITH_DEPENDENCIES: Symbol('CURRIED_FUNCTION_WITH_DEPENDENCIES'), 17 | CURRIED_FUNCTION_WITH_DEPENDENCIES_OBJECT: Symbol('CURRIED_FUNCTION_WITH_DEPENDENCIES_OBJECT'), 18 | CIRCULAR_A: Symbol('CIRCULAR_A'), 19 | CIRCULAR_B: Symbol('CIRCULAR_B'), 20 | } ; 21 | -------------------------------------------------------------------------------- /specs/examples/HigherOrderFunctions.ts: -------------------------------------------------------------------------------- 1 | import {HigherOrderFunctionDependencies, MyServiceInterface, ServiceWithoutDependencyInterface} from "./types"; 2 | 3 | export const HigherOrderFunctionWithoutDependency = (): ServiceWithoutDependencyInterface => ({ 4 | run() { 5 | return 'OtherService'; 6 | } 7 | }); 8 | 9 | export const HigherOrderFunctionWithDependencies = (dep1: string, dep2: number): MyServiceInterface => ({ 10 | runTask() { 11 | return `Executing with dep1: ${dep1} and dep2: ${dep2}`; 12 | } 13 | }); 14 | 15 | export const HigherOrderFunctionWithDependencyObject = ({ dep1, dep2 }: HigherOrderFunctionDependencies): MyServiceInterface => ({ 16 | runTask() { 17 | return `Executing with dep1: ${dep1} and dep2: ${dep2}`; 18 | } 19 | }); 20 | 21 | -------------------------------------------------------------------------------- /specs/examples/SimpleFunctions.ts: -------------------------------------------------------------------------------- 1 | import {SayHelloType} from "./types"; 2 | 3 | export const sayHelloWorld: SayHelloType = () => 'hello world'; -------------------------------------------------------------------------------- /specs/examples/UseCase.ts: -------------------------------------------------------------------------------- 1 | import {MyUseCaseInterface, UseCaseDependencies} from "./types"; 2 | 3 | export function UseCase({myService, logger, sayHello}: UseCaseDependencies): MyUseCaseInterface { 4 | return { 5 | execute() { 6 | const message = myService.runTask(); 7 | logger.log(message); 8 | logger.log(sayHello()); 9 | return message; 10 | } 11 | }; 12 | } -------------------------------------------------------------------------------- /specs/examples/types.ts: -------------------------------------------------------------------------------- 1 | export interface MyServiceClassInterface { 2 | runTask(): string; 3 | } 4 | 5 | export interface Dependencies { 6 | dep1: string; 7 | dep2: number; 8 | } 9 | 10 | export interface ServiceWithoutDependencyInterface { 11 | run: () => string; 12 | } 13 | 14 | export type SayHelloType = () => string; 15 | 16 | export interface MyUseCaseInterface { 17 | execute: () => string; 18 | } 19 | 20 | export interface MyServiceInterface { 21 | runTask: () => string; 22 | } 23 | 24 | export interface LoggerInterface { 25 | log: (message: string) => void; 26 | } 27 | 28 | export interface UseCaseDependencies { 29 | myService: MyServiceInterface, 30 | logger: LoggerInterface, 31 | sayHello: SayHelloType 32 | } 33 | 34 | export type CurriedFunctionWithDependencies = (name: string) => string; 35 | 36 | export type CurriedFunctionWithoutDependencies = () => string; 37 | 38 | export interface HigherOrderFunctionDependencies { 39 | dep1: string, 40 | dep2: number 41 | } -------------------------------------------------------------------------------- /specs/module.spec.ts: -------------------------------------------------------------------------------- 1 | import {Container, createContainer, createModule} from "../src"; 2 | import {DI} from "./examples/DI"; 3 | import {MyServiceInterface, SayHelloType} from "./examples/types"; 4 | import {HigherOrderFunctionWithDependencyObject} from "./examples/HigherOrderFunctions"; 5 | import {sayHelloWorld} from "./examples/SimpleFunctions"; 6 | 7 | describe('Module', () => { 8 | 9 | let container: Container; 10 | 11 | beforeEach(() => { 12 | container = createContainer(); 13 | }); 14 | 15 | describe('When a module is loaded', () => { 16 | describe.each([Symbol('myModule'), 'myModule']) 17 | ('When the module has dependencies', (moduleKey) => { 18 | it(`should return all dependencies of module with key: ${moduleKey.toString()}`, () => { 19 | // Arrange 20 | const myModule = createModule(); 21 | myModule.bind('SIMPLE_FUNCTION').toFunction(sayHelloWorld); 22 | container.load(moduleKey, myModule); 23 | 24 | // Act 25 | const sayHello = container.get('SIMPLE_FUNCTION'); 26 | 27 | // Assert 28 | expect(sayHello()).toBe('hello world'); 29 | }); 30 | }); 31 | 32 | describe('When a dependency of the module is registered in another module', () => { 33 | it('should correctly resolve all dependencies', () => { 34 | // Arrange 35 | const module1 = createModule(); 36 | module1.bind(DI.DEP1).toValue('dependency1'); 37 | 38 | const module2 = createModule(); 39 | module2.bind(DI.DEP2).toValue(42); 40 | 41 | const module3 = createModule(); 42 | module3.bind(DI.MY_SERVICE).toHigherOrderFunction(HigherOrderFunctionWithDependencyObject, { 43 | dep1: DI.DEP1, 44 | dep2: DI.DEP2 45 | }); 46 | 47 | container.load(Symbol('module1'), module1); 48 | container.load(Symbol('module2'), module2); 49 | container.load(Symbol('module3'), module3); 50 | 51 | // Act 52 | const myService = container.get(DI.MY_SERVICE); 53 | 54 | // Assert 55 | expect(myService.runTask()).toBe('Executing with dep1: dependency1 and dep2: 42'); 56 | }); 57 | 58 | it('should take the last registered values', () => { 59 | // Arrange 60 | const module1 = createModule(); 61 | module1.bind(DI.DEP1).toValue('OLD dependency1'); 62 | module1.bind(DI.MY_SERVICE).toFunction(sayHelloWorld); 63 | 64 | const module2 = createModule(); 65 | module2.bind(DI.DEP1).toValue('NEW dependency1'); 66 | 67 | const module3 = createModule(); 68 | module3.bind(DI.MY_SERVICE).toHigherOrderFunction(HigherOrderFunctionWithDependencyObject, { 69 | dep1: DI.DEP1, 70 | dep2: DI.DEP2 71 | }); 72 | 73 | container.bind(DI.DEP2).toValue(42); 74 | container.load(Symbol('module1'), module1); 75 | container.load(Symbol('module2'), module2); 76 | container.load(Symbol('module3'), module3); 77 | 78 | // Act 79 | const myService = container.get(DI.MY_SERVICE); 80 | 81 | // Assert 82 | expect(myService.runTask()).toBe('Executing with dep1: NEW dependency1 and dep2: 42'); 83 | }); 84 | }); 85 | }); 86 | 87 | describe('When a module is unloaded', () => { 88 | describe('When another module has this dependency already registered', () => { 89 | it('should use the existing dependency', () => { 90 | // Arrange 91 | const MODULE1 = Symbol('myModule1'); 92 | const MODULE2 = Symbol('myModule2'); 93 | 94 | const module1 = createModule(); 95 | module1.bind(DI.SIMPLE_FUNCTION).toFunction(() => { 96 | return 'module 1 hello world'; 97 | }); 98 | container.load(MODULE1, module1); 99 | 100 | const module2 = createModule(); 101 | module2.bind(DI.SIMPLE_FUNCTION).toFunction(sayHelloWorld); 102 | container.load(MODULE2, module2); 103 | 104 | const sayHelloBeforeUnload = container.get(DI.SIMPLE_FUNCTION); 105 | expect(sayHelloBeforeUnload()).toBe('hello world'); 106 | 107 | // Act 108 | container.unload(MODULE2); 109 | 110 | // Assert 111 | const sayHelloAfterUnload = container.get(DI.SIMPLE_FUNCTION); 112 | expect(sayHelloAfterUnload()).toBe('module 1 hello world'); 113 | }); 114 | }); 115 | 116 | describe('When no other module has this dependency already registered', () => { 117 | it('should remove all its dependencies', () => { 118 | // Arrange 119 | const MY_MODULE = Symbol('myModule'); 120 | 121 | const module = createModule(); 122 | module.bind(DI.SIMPLE_FUNCTION).toFunction(sayHelloWorld); 123 | container.load(MY_MODULE, module); 124 | 125 | const sayHelloBeforeUnload = container.get(DI.SIMPLE_FUNCTION); 126 | expect(sayHelloBeforeUnload()).toBe('hello world'); 127 | 128 | // Act 129 | container.unload(MY_MODULE); 130 | 131 | // Assert 132 | expect(() => container.get(DI.SIMPLE_FUNCTION)) 133 | .toThrowError(`No binding found for key: ${DI.SIMPLE_FUNCTION.toString()}`); 134 | }); 135 | }); 136 | }); 137 | }); -------------------------------------------------------------------------------- /specs/scope.spec.ts: -------------------------------------------------------------------------------- 1 | import {Container, createContainer, Scope} from "../src"; 2 | import {DI} from "./examples/DI"; 3 | import {Mock, vi} from "vitest"; 4 | import {MyServiceClass} from "./examples/Classes"; 5 | import {CurriedFunctionWithDependencies, MyServiceClassInterface, MyServiceInterface} from "./examples/types"; 6 | import {curriedFunctionWithDependencyObject} from "./examples/Currying"; 7 | import {HigherOrderFunctionWithDependencyObject} from "./examples/HigherOrderFunctions"; 8 | 9 | describe('Scope', () => { 10 | 11 | let container: Container; 12 | let factoryCalls: Mock; 13 | 14 | beforeEach(() => { 15 | container = createContainer(); 16 | container.bind(DI.DEP1).toValue('dependency1'); 17 | container.bind(DI.DEP2).toValue(42); 18 | factoryCalls = vi.fn(); 19 | }); 20 | 21 | describe('Factories', () => { 22 | 23 | describe.each([ 24 | {scope: undefined}, 25 | {scope: 'singleton'}, 26 | ])('When the scope is default or defined to "singleton"', ({scope}) => { 27 | it('should return the same instance', () => { 28 | // Arrange 29 | container.bind(DI.MY_SERVICE).toFactory(() => { 30 | factoryCalls(); 31 | return HigherOrderFunctionWithDependencyObject({ 32 | dep1: container.get(DI.DEP1), 33 | dep2: container.get(DI.DEP2) 34 | }); 35 | }, scope as Scope); 36 | 37 | const myService1 = container.get(DI.MY_SERVICE); 38 | 39 | // Act 40 | const myService2 = container.get(DI.MY_SERVICE); 41 | 42 | // Assert 43 | expect(myService1).toBe(myService2); 44 | expect(factoryCalls).toHaveBeenCalledTimes(1); 45 | }); 46 | }); 47 | 48 | describe('When the scope is defined to "transient"', () => { 49 | it('should return a new instance each time', () => { 50 | // Arrange 51 | container.bind(DI.MY_SERVICE).toFactory(() => { 52 | factoryCalls(); 53 | return HigherOrderFunctionWithDependencyObject({ 54 | dep1: container.get(DI.DEP1), 55 | dep2: container.get(DI.DEP2) 56 | }); 57 | }, 'transient'); 58 | 59 | const myService1 = container.get(DI.MY_SERVICE); 60 | 61 | // Act 62 | const myService2 = container.get(DI.MY_SERVICE); 63 | 64 | // Assert 65 | expect(myService1).not.toBe(myService2); 66 | expect(factoryCalls).toHaveBeenCalledTimes(2); 67 | }); 68 | }); 69 | 70 | describe('When the scope is defined to "scoped"', () => { 71 | it('should return the same instance within the same scope', () => { 72 | // Arrange 73 | container.bind(DI.DEP1).toValue('dependency1'); 74 | container.bind(DI.DEP2).toValue(42); 75 | container.bind(DI.MY_SERVICE).toFactory(() => { 76 | factoryCalls(); 77 | return HigherOrderFunctionWithDependencyObject({ 78 | dep1: container.get(DI.DEP1), 79 | dep2: container.get(DI.DEP2) 80 | }); 81 | }, 'scoped'); 82 | 83 | let myService1: MyServiceInterface | undefined; 84 | let myService2: MyServiceInterface | undefined; 85 | 86 | // Act 87 | container.runInScope(() => { 88 | myService1 = container.get(DI.MY_SERVICE); 89 | myService2 = container.get(DI.MY_SERVICE); 90 | }); 91 | 92 | // Assert 93 | expect(myService1).toBeDefined(); 94 | expect(myService2).toBeDefined(); 95 | expect(myService1).toBe(myService2); 96 | expect(factoryCalls).toHaveBeenCalledTimes(1); 97 | }); 98 | 99 | it('should return different instances in different scopes', () => { 100 | // Arrange 101 | container.bind(DI.MY_SERVICE).toFactory(() => { 102 | factoryCalls(); 103 | return HigherOrderFunctionWithDependencyObject({ 104 | dep1: container.get(DI.DEP1), 105 | dep2: container.get(DI.DEP2) 106 | }); 107 | }, 'scoped'); 108 | 109 | let myService1: MyServiceInterface | undefined; 110 | let myService2: MyServiceInterface | undefined; 111 | 112 | container.runInScope(() => { 113 | myService1 = container.get(DI.MY_SERVICE); 114 | }); 115 | 116 | // Act 117 | container.runInScope(() => { 118 | myService2 = container.get(DI.MY_SERVICE); 119 | }); 120 | 121 | // Assert 122 | expect(myService1).toBeDefined(); 123 | expect(myService2).toBeDefined(); 124 | expect(myService1).not.toBe(myService2); 125 | expect(factoryCalls).toHaveBeenCalledTimes(2); 126 | }); 127 | }); 128 | }); 129 | 130 | describe('Classes', () => { 131 | 132 | describe.each([ 133 | {scope: undefined}, 134 | {scope: 'singleton'}, 135 | ])('When the scope is default or defined to "singleton"', ({scope}) => { 136 | it('should return the same instance', () => { 137 | // Arrange 138 | container.bind(DI.MY_SERVICE).toClass(MyServiceClass, [DI.DEP1, DI.DEP2], scope as Scope); 139 | 140 | const myService1 = container.get(DI.MY_SERVICE); 141 | 142 | // Act 143 | const myService2 = container.get(DI.MY_SERVICE); 144 | 145 | // Assert 146 | expect(myService1).toBe(myService2); 147 | }); 148 | }); 149 | 150 | describe('When the scope is defined to "transient"', () => { 151 | it('should return a new instance each time', () => { 152 | // Arrange 153 | container.bind(DI.MY_SERVICE).toClass(MyServiceClass, [DI.DEP1, DI.DEP2], 'transient'); 154 | 155 | const myService1 = container.get(DI.MY_SERVICE); 156 | 157 | // Act 158 | const myService2 = container.get(DI.MY_SERVICE); 159 | 160 | // Assert 161 | expect(myService1).not.toBe(myService2); 162 | }); 163 | }); 164 | }); 165 | 166 | describe('Higher order functions', () => { 167 | 168 | describe.each([ 169 | {scope: undefined}, 170 | {scope: 'singleton'}, 171 | ])('When the scope is default or defined to "singleton"', ({scope}) => { 172 | it('should return the same instance', () => { 173 | // Arrange 174 | container.bind(DI.MY_SERVICE) 175 | .toHigherOrderFunction(HigherOrderFunctionWithDependencyObject, {dep1: DI.DEP1, dep2: DI.DEP2}, scope as Scope); 176 | 177 | const myService1 = container.get(DI.MY_SERVICE); 178 | 179 | // Act 180 | const myService2 = container.get(DI.MY_SERVICE); 181 | 182 | // Assert 183 | expect(myService1).toBe(myService2); 184 | }); 185 | }); 186 | 187 | describe('When the scope is defined to "transient"', () => { 188 | it('should return a new instance each time', () => { 189 | // Arrange 190 | container.bind(DI.MY_SERVICE) 191 | .toHigherOrderFunction(HigherOrderFunctionWithDependencyObject, {dep1: DI.DEP1, dep2: DI.DEP2}, 'transient'); 192 | 193 | const myService1 = container.get(DI.MY_SERVICE); 194 | 195 | // Act 196 | const myService2 = container.get(DI.MY_SERVICE); 197 | 198 | // Assert 199 | expect(myService1).not.toBe(myService2); 200 | }); 201 | }); 202 | }); 203 | 204 | describe('Curry', () => { 205 | 206 | describe.each([ 207 | {scope: undefined}, 208 | {scope: 'singleton'}, 209 | ])('When the scope is default or defined to "singleton"', ({scope}) => { 210 | it('should return the same instance', () => { 211 | // Arrange 212 | container.bind(DI.MY_SERVICE) 213 | .toCurry(curriedFunctionWithDependencyObject, {dep1: DI.DEP1, dep2: DI.DEP2}, scope as Scope); 214 | 215 | const myService1 = container.get(DI.MY_SERVICE); 216 | 217 | // Act 218 | const myService2 = container.get(DI.MY_SERVICE); 219 | 220 | // Assert 221 | expect(myService1).toBe(myService2); 222 | }); 223 | }); 224 | 225 | describe('When the scope is defined to "transient"', () => { 226 | it('should return a new instance each time', () => { 227 | // Arrange 228 | container.bind(DI.MY_SERVICE) 229 | .toCurry(curriedFunctionWithDependencyObject, {dep1: DI.DEP1, dep2: DI.DEP2}, 'transient'); 230 | 231 | const myService1 = container.get(DI.MY_SERVICE); 232 | 233 | // Act 234 | const myService2 = container.get(DI.MY_SERVICE); 235 | 236 | // Assert 237 | expect(myService1).not.toBe(myService2); 238 | }); 239 | }); 240 | }); 241 | 242 | describe('When a scoped dependency is resolved outside of a scope', () => { 243 | it('should throw an error', () => { 244 | // Arrange 245 | container.bind(DI.MY_SERVICE) 246 | .toHigherOrderFunction(HigherOrderFunctionWithDependencyObject, {dep1: DI.DEP1, dep2: DI.DEP2}, 'scoped'); 247 | 248 | // Act & Assert 249 | expect(() => container.get(DI.MY_SERVICE)) 250 | .toThrowError(`Cannot resolve scoped binding outside of a scope: ${DI.MY_SERVICE.toString()}`); 251 | }); 252 | }); 253 | 254 | describe('When an unknown scope is used during binding', () => { 255 | it('should throw an error', () => { 256 | // Arrange 257 | container.bind(DI.MY_SERVICE).toClass(MyServiceClass, [], 'unknown' as any); 258 | 259 | // Act & Assert 260 | expect(() => container.get(DI.MY_SERVICE)) 261 | .toThrowError('Unknown scope: unknown'); 262 | }); 263 | }); 264 | }); 265 | -------------------------------------------------------------------------------- /src/container.ts: -------------------------------------------------------------------------------- 1 | import {Binding, Container, DependencyKey, Module, ModuleKey} from './types'; 2 | import {createModule} from './module'; 3 | 4 | export function createContainer(): Container { 5 | const modules = new Map(); 6 | const singletonInstances = new Map(); 7 | const scopedInstances = new Map>(); 8 | const resolutionStack: DependencyKey[] = []; 9 | let currentScopeId: symbol | undefined; 10 | 11 | const DEFAULT_MODULE_KEY = Symbol('DEFAULT'); 12 | const defaultModule = createModule(); 13 | modules.set(DEFAULT_MODULE_KEY, defaultModule); 14 | 15 | const bind = (key: DependencyKey) => defaultModule.bind(key); 16 | 17 | const load = (moduleKey: symbol, module: Module) => modules.set(moduleKey, module); 18 | 19 | const unload = (moduleKey: ModuleKey) => { 20 | singletonInstances.clear(); 21 | modules.delete(moduleKey); 22 | }; 23 | 24 | const findLastBinding = (key: DependencyKey): Binding | null => { 25 | const modulesArray = Array.from(modules.values()); 26 | for (let i = modulesArray.length - 1; i >= 0; i--) { 27 | const module = modulesArray[i]; 28 | const binding = module.bindings.get(key); 29 | if (binding) { 30 | return binding as Binding; 31 | } 32 | } 33 | return null; 34 | }; 35 | 36 | const getLastBinding = (key: DependencyKey): Binding => { 37 | const binding = findLastBinding(key); 38 | if (!binding) { 39 | throw new Error(`No binding found for key: ${key.toString()}`); 40 | } 41 | return binding; 42 | }; 43 | 44 | const isCircularDependency = (key: DependencyKey): boolean => resolutionStack.includes(key); 45 | 46 | const buildCycleOf = (key: DependencyKey) => [...resolutionStack, key].map((k) => k.toString()).join(' -> '); 47 | 48 | const startCircularDependencyDetectionFor = (dependencyKey: DependencyKey) => resolutionStack.push(dependencyKey); 49 | 50 | const endCircularDependencyDetection = () => resolutionStack.pop(); 51 | 52 | const get = (dependencyKey: DependencyKey): T => { 53 | if (isCircularDependency(dependencyKey)) { 54 | const cycle = buildCycleOf(dependencyKey); 55 | throw new Error(`Circular dependency detected: ${cycle}`); 56 | } 57 | 58 | startCircularDependencyDetectionFor(dependencyKey); 59 | 60 | try { 61 | const binding = getLastBinding(dependencyKey); 62 | 63 | const {factory, scope} = binding; 64 | 65 | if (scope === 'singleton') { 66 | if (!singletonInstances.has(dependencyKey)) { 67 | singletonInstances.set(dependencyKey, factory(resolveDependency)); 68 | } 69 | return singletonInstances.get(dependencyKey) as T; 70 | } 71 | 72 | if (scope === 'transient') { 73 | return factory(resolveDependency) as T; 74 | } 75 | 76 | if (scope === 'scoped') { 77 | if (!currentScopeId) { 78 | throw new Error(`Cannot resolve scoped binding outside of a scope: ${dependencyKey.toString()}`); 79 | } 80 | 81 | if (!scopedInstances.has(currentScopeId)) { 82 | scopedInstances.set(currentScopeId, new Map()); 83 | } 84 | const scopeMap = scopedInstances.get(currentScopeId)!; 85 | if (!scopeMap.has(dependencyKey)) { 86 | scopeMap.set(dependencyKey, factory(resolveDependency)); 87 | } 88 | 89 | return scopeMap.get(dependencyKey) as T; 90 | } 91 | 92 | throw new Error(`Unknown scope: ${scope}`); 93 | } finally { 94 | endCircularDependencyDetection(); 95 | } 96 | }; 97 | 98 | const resolveDependency = (depKey: DependencyKey): unknown => { 99 | return get(depKey); 100 | }; 101 | 102 | const runInScope = (callback: () => T): T => { 103 | const previousScopeId = currentScopeId; 104 | currentScopeId = Symbol('scope'); 105 | try { 106 | return callback(); 107 | } finally { 108 | scopedInstances.delete(currentScopeId); 109 | currentScopeId = previousScopeId; 110 | } 111 | }; 112 | 113 | return {bind, load, get, unload, runInScope}; 114 | } 115 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './container'; 2 | export * from './module'; 3 | export * from './types'; -------------------------------------------------------------------------------- /src/module.ts: -------------------------------------------------------------------------------- 1 | import {DependencyArray, DependencyKey, DependencyObject, Module, ResolveFunction, Scope} from "./types"; 2 | 3 | interface Binding { 4 | factory: (resolve: ResolveFunction) => unknown; 5 | scope: Scope; 6 | } 7 | 8 | export function createModule(): Module { 9 | const bindings = new Map(); 10 | 11 | const resolveDependenciesArray = (dependencies: DependencyArray, resolve: ResolveFunction) => 12 | dependencies.map(resolve); 13 | 14 | const resolveDependenciesObject = (dependencies: DependencyObject, resolve: ResolveFunction) => { 15 | const entries = Object.entries(dependencies); 16 | return Object.fromEntries(entries.map(([key, dependency]) => [key, resolve(dependency)])); 17 | }; 18 | 19 | const isDependencyArray = (dependencies: DependencyArray | DependencyObject): dependencies is DependencyArray => 20 | Array.isArray(dependencies); 21 | 22 | const isDependencyObject = (dependencies: DependencyArray | DependencyObject): dependencies is DependencyObject => 23 | dependencies !== null && typeof dependencies === "object" && !Array.isArray(dependencies); 24 | 25 | const bind = (key: DependencyKey) => { 26 | const toValue = (value: unknown) => { 27 | bindings.set(key, {factory: () => value, scope: 'singleton'}); 28 | }; 29 | 30 | const toFunction = (fn: CallableFunction) => { 31 | bindings.set(key, {factory: () => fn, scope: 'singleton'}); 32 | }; 33 | 34 | const toHigherOrderFunction = ( 35 | fn: CallableFunction, 36 | dependencies?: DependencyArray | DependencyObject, 37 | scope: Scope = 'singleton' 38 | ) => { 39 | if (dependencies && !isDependencyArray(dependencies) && !isDependencyObject(dependencies)) { 40 | throw new Error("Invalid dependencies type"); 41 | } 42 | 43 | const factory = (resolve: ResolveFunction) => { 44 | if (!dependencies) { 45 | return fn(); 46 | } 47 | 48 | if (isDependencyArray(dependencies)) { 49 | return fn(...resolveDependenciesArray(dependencies, resolve)); 50 | } 51 | 52 | return fn({...resolveDependenciesObject(dependencies, resolve)}); 53 | }; 54 | 55 | bindings.set(key, {factory, scope}); 56 | }; 57 | 58 | const toCurry = toHigherOrderFunction; 59 | 60 | const toFactory = (factory: CallableFunction, scope: Scope = 'singleton') => { 61 | bindings.set(key, {factory: (resolve: ResolveFunction) => factory(resolve), scope}); 62 | }; 63 | 64 | const toClass = ( 65 | AnyClass: new (...args: unknown[]) => unknown, 66 | dependencies?: DependencyArray | DependencyObject, 67 | scope: Scope = 'singleton' 68 | ) => { 69 | 70 | if (dependencies && !isDependencyArray(dependencies) && !isDependencyObject(dependencies)) { 71 | throw new Error("Invalid dependencies type"); 72 | } 73 | 74 | const factory = (resolve: ResolveFunction) => { 75 | if (!dependencies) { 76 | return new AnyClass(); 77 | } 78 | 79 | if (isDependencyArray(dependencies)) { 80 | const resolvedDeps = resolveDependenciesArray(dependencies, resolve); 81 | return new AnyClass(...resolvedDeps); 82 | } 83 | 84 | if (isDependencyObject(dependencies)) { 85 | const resolvedDeps = resolveDependenciesObject(dependencies, resolve); 86 | return new AnyClass({...resolvedDeps}); 87 | } 88 | }; 89 | 90 | bindings.set(key, {factory, scope}); 91 | }; 92 | 93 | return { 94 | toValue, 95 | toFunction, 96 | toFactory, 97 | toClass, 98 | toHigherOrderFunction, 99 | toCurry 100 | }; 101 | }; 102 | 103 | return {bind, bindings}; 104 | } 105 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type DependencyKey = symbol | string; 2 | 3 | export type ModuleKey = symbol | string; 4 | 5 | export interface DependencyObject { 6 | [key: string]: DependencyKey; 7 | } 8 | 9 | export type DependencyArray = DependencyKey[]; 10 | 11 | export type Scope = 'singleton' | 'transient' | 'scoped'; 12 | 13 | interface Bindable { 14 | bind(key: DependencyKey): { 15 | toValue: (value: unknown) => void; 16 | toFunction: (fn: CallableFunction) => void; 17 | toHigherOrderFunction: ( 18 | fn: CallableFunction, 19 | dependencies?: DependencyArray | DependencyObject, 20 | scope?: Scope 21 | ) => void; 22 | toCurry: ( 23 | fn: CallableFunction, 24 | dependencies?: DependencyArray | DependencyObject, 25 | scope?: Scope 26 | ) => void; 27 | toFactory: (factory: CallableFunction, scope?: Scope) => void; 28 | toClass: ( 29 | constructor: new (...args: any[]) => C, 30 | dependencies?: DependencyArray | DependencyObject, 31 | scope?: Scope 32 | ) => void; 33 | }; 34 | } 35 | 36 | export interface Container extends Bindable { 37 | load(moduleKey: ModuleKey, module: Module): void; 38 | 39 | get(key: DependencyKey): T; 40 | 41 | unload(key: ModuleKey): void; 42 | 43 | runInScope(callback: () => T): T; 44 | } 45 | 46 | export interface Module extends Bindable { 47 | bindings: Map; 48 | } 49 | 50 | export interface InjectionTokens { 51 | [key: string]: DependencyKey; 52 | } 53 | 54 | export type ResolveFunction = (dep: DependencyKey) => unknown; 55 | 56 | export interface Binding { 57 | factory: (resolve: (key: DependencyKey) => unknown) => unknown; 58 | scope: Scope; 59 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "declaration": true, 5 | "esModuleInterop": true, 6 | "forceConsistentCasingInFileNames": true, 7 | "isolatedModules": true, 8 | "module": "ESNext", 9 | "moduleDetection": "force", 10 | "moduleResolution": "Bundler", 11 | "noEmit": true, 12 | "resolveJsonModule": true, 13 | "skipLibCheck": true, 14 | "sourceMap": true, 15 | "strict": true, 16 | "target": "es2022", 17 | "types": ["vitest/globals"] 18 | }, 19 | "include": [ 20 | "src/**/*.ts", 21 | "specs/**/*.ts" 22 | ], 23 | "exclude": ["node_modules"] 24 | } 25 | -------------------------------------------------------------------------------- /vitest.config.mts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | coverage: { 6 | provider: 'v8', 7 | include: ["src/**/**/*.ts"], 8 | exclude: ["src/**/**/types.ts"], 9 | }, 10 | globals: true, 11 | setupFiles: ['./vitest.setup.mts'], 12 | mockReset: true, 13 | clearMocks: true, 14 | restoreMocks: true, 15 | }, 16 | }) -------------------------------------------------------------------------------- /vitest.setup.mts: -------------------------------------------------------------------------------- 1 | import * as jestExtendedMatchers from 'jest-extended'; 2 | 3 | import {expect} from 'vitest'; 4 | 5 | expect.extend(jestExtendedMatchers); --------------------------------------------------------------------------------