├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ ├── build.yml │ ├── npm.yaml │ └── pages.yaml ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .prettierrc.json ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── jest.config.ts ├── package-lock.json ├── package.json ├── src ├── Container.ts ├── Injectable.ts ├── PartialContainer.ts ├── __tests__ │ ├── Container.spec.ts │ ├── Injectable.spec.ts │ ├── PartialContainer.spec.ts │ └── types.spec.ts ├── entries.ts ├── index.ts ├── memoize.ts └── types.ts ├── tsconfig.cjs.json ├── tsconfig.esm.json ├── tsconfig.jest.json ├── tsconfig.json └── tsconfig.types.json /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es6: true, 5 | }, 6 | root: true, 7 | parser: "@typescript-eslint/parser", 8 | plugins: ["@typescript-eslint", "eslint-plugin-import", "eslint-plugin-prefer-arrow"], 9 | rules: { 10 | "@typescript-eslint/member-ordering": [ 11 | "error", 12 | {"classes": [ 13 | "static-field", 14 | "static-method", 15 | "public-instance-field", 16 | "protected-instance-field", 17 | "#private-instance-field", 18 | "constructor", 19 | "public-instance-method", 20 | "protected-instance-method", 21 | "#private-instance-method", 22 | ] }, 23 | ], 24 | "@typescript-eslint/consistent-type-imports": "error", 25 | "@typescript-eslint/explicit-module-boundary-types": "off", 26 | "@typescript-eslint/no-explicit-any": "off", 27 | "import/order": "error", 28 | "max-classes-per-file": ["error", 1], 29 | "max-len": [ 30 | "error", 31 | { 32 | code: 120, 33 | }, 34 | ], 35 | }, 36 | }; 37 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | jobs: 13 | build: 14 | name: Build 15 | runs-on: ubuntu-latest 16 | timeout-minutes: 10 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v4 20 | 21 | - name: Set up Node.js 20.x 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: 20.x 25 | cache: 'npm' 26 | 27 | - name: Install dependencies 28 | run: npm ci 29 | 30 | - name: Run tests 31 | run: npm test 32 | 33 | - name: Compile the code 34 | run: npm run compile 35 | 36 | - name: Run styleguide 37 | run: npm run styleguide 38 | -------------------------------------------------------------------------------- /.github/workflows/npm.yaml: -------------------------------------------------------------------------------- 1 | name: Publish to npm 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v[0-9]+.[0-9]+.[0-9]+' 7 | - 'v[0-9]+.[0-9]+.[0-9]+-alpha.[0-9]+' 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | timeout-minutes: 10 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v4 16 | 17 | - name: Set up Node.js 20.x 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: 20.x 21 | cache: 'npm' 22 | registry-url: 'https://registry.npmjs.org/' 23 | 24 | - name: Determine pre-release tag 25 | id: release-tag 26 | run: | 27 | TAG_NAME=${GITHUB_REF#refs/tags/} 28 | echo "Detected tag: $TAG_NAME" 29 | if [[ "$TAG_NAME" == *-alpha.* ]]; then 30 | echo "tag=alpha" >> $GITHUB_ENV 31 | npm version --no-git-tag-version $TAG_NAME 32 | else 33 | echo "tag=latest" >> $GITHUB_ENV 34 | fi 35 | 36 | - name: Install dependencies 37 | run: npm ci 38 | 39 | - name: Run tests 40 | run: npm test 41 | 42 | - name: Compile the code 43 | run: npm run compile 44 | 45 | - name: Publish 46 | run: npm publish --access public --tag ${{ env.tag }} 47 | env: 48 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 49 | -------------------------------------------------------------------------------- /.github/workflows/pages.yaml: -------------------------------------------------------------------------------- 1 | # Simple workflow for deploying static content to GitHub Pages 2 | name: Deploy docs to Pages 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: ["main"] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 19 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 20 | concurrency: 21 | group: "pages" 22 | cancel-in-progress: false 23 | 24 | jobs: 25 | # Single deploy job since we're just deploying 26 | deploy: 27 | environment: 28 | name: github-pages 29 | url: ${{ steps.deployment.outputs.page_url }} 30 | runs-on: ubuntu-latest 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v4 34 | 35 | - name: Setup Pages 36 | uses: actions/configure-pages@v5 37 | 38 | - name: Set up Node.js 20.x 39 | uses: actions/setup-node@v4 40 | with: 41 | node-version: 20.x 42 | cache: 'npm' 43 | 44 | - name: Install dependencies 45 | run: npm ci 46 | 47 | - name: Generate docs 48 | run: npm run docs 49 | 50 | - name: Upload artifact 51 | uses: actions/upload-pages-artifact@v3 52 | with: 53 | path: './docs' 54 | 55 | - name: Deploy to GitHub Pages 56 | id: deployment 57 | uses: actions/deploy-pages@v4 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | docs 3 | node_modules 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v20.15.0 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | docs 3 | node_modules 4 | package-lock.json 5 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "trailingComma": "es5", 4 | "arrowParens": "always" 5 | } 6 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing guide 2 | 3 | Thanks for taking the time to contribute! We appreciate contributions whether it's: 4 | 5 | - Reporting a bug 6 | - Submitting a fix or new feature 7 | - Proposing new features 8 | 9 | ## Report bugs using Github [issues](../../issues) 10 | 11 | We use GitHub [issues](../../issues) to track public bugs. Report a bug by opening a new issue; it's easy! 12 | 13 | When reporting bugs, please include enough details so that it can be investigated. Bug reports should have: 14 | 15 | - A summary or background 16 | - Steps to reproduce 17 | - Give code sample if you can 18 | - What is the expected result 19 | - What actually happens 20 | 21 | ## Contributing fixes and features 22 | 23 | Pull requests are the best way to propose code changes: 24 | 25 | 1. Fork the repo and create your branch from `main`. 26 | 2. Run `nvm use && npm ci` and check in any changes to generated code. 27 | 3. Add tests, if appropriate. 28 | 4. Run `npm test` to ensure the test suite passes with your change. 29 | 5. Run `npm run styleguide:fix` to ensure coding guidelines are followed. 30 | 6. Issue the pull request. 31 | 32 | ## License 33 | 34 | Any contributions you make will be under the same MIT license that covers the project. 35 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 Snap Inc. 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ts-inject 2 | 3 | `ts-inject` is a 100% typesafe dependency injection framework for TypeScript projects, designed to enhance code sharing and modularity by ensuring compile-time dependency resolution. This framework leverages the dependency injection design pattern to decouple dependency usage from creation, allowing components to rely on interfaces rather than implementations. 4 | 5 | ## Features and Alternatives 6 | 7 | `ts-inject` brings typesafety to dependency injection, setting it apart from a vast majority of frameworks, like [InversifyJS](https://github.com/inversify/InversifyJS), which operate at runtime and therefore lack this level of typesafety. 8 | 9 | While [typed-inject](https://github.com/nicojs/typed-inject) also prioritizes typesafety, it lacks several key features that `ts-inject` offers: 10 | 11 | - **Overcomes TypeScript Nested Type Limitations**: Unlike some frameworks, `ts-inject` navigates around [TypeScript's limits on nested types](https://github.com/nicojs/typed-inject/issues/22), making it more robust for complex applications. 12 | - **Composable Containers**: `ts-inject` enables merging multiple containers, facilitating greater modularity and code reuse. 13 | - **PartialContainer**: It allows service registration without pre-defined dependencies, offering more flexibility compared to regular containers. 14 | 15 | ## Getting Started 16 | 17 | ### Installation 18 | 19 | ```bash 20 | npm install @snap/ts-inject 21 | ``` 22 | 23 | ### Sample Usage 24 | 25 | This quick start guide demonstrates how to define services, register them in a container, and then retrieve them for use. 26 | 27 | #### Defining Services 28 | 29 | Define a couple of services. For simplicity, we'll use a `Logger` service and a `Database` service, where `Database` depends on `Logger` for logging purposes. 30 | 31 | ```ts 32 | // Logger service definition 33 | class Logger { 34 | log(message: string) { 35 | console.log(`Log: ${message}`); 36 | } 37 | } 38 | 39 | // Database service depends on Logger 40 | class Database { 41 | constructor(private logger: Logger) {} 42 | 43 | save(record: string) { 44 | this.logger.log(`Saving record: ${record}`); 45 | // Assume record saving logic here 46 | } 47 | } 48 | ``` 49 | 50 | #### Setting Up the Container 51 | 52 | With `ts-inject`, you can easily set up a container to manage these services: 53 | 54 | ```ts 55 | import { Container, Injectable } from "@snap/ts-inject"; 56 | 57 | // Define Injectable factory functions for services 58 | const loggerFactory = Injectable("Logger", () => new Logger()); 59 | const databaseFactory = Injectable("Database", ["Logger"] as const, (logger: Logger) => new Database(logger)); 60 | 61 | // Create a container and register services 62 | const container = Container.provides(loggerFactory).provides(databaseFactory); 63 | 64 | // Now, retrieve the Database service from the container 65 | const db = container.get("Database"); 66 | db.save("user1"); // Log: Saving record: user1 67 | ``` 68 | 69 | #### Composable Containers 70 | 71 | `ts-inject` supports composable containers, allowing you to modularize service registration: 72 | 73 | ```ts 74 | const baseContainer = Container.provides(loggerFactory); 75 | const appContainer = Container.provides(baseContainer).provide(databaseFactory); 76 | 77 | const db: Database = appContainer.get("Database"); 78 | db.save("user2"); // Log: Saving record: user2 79 | ``` 80 | 81 | ### Key Concepts 82 | 83 | - **Container**: A registry for all services, handling their creation and retrieval. 84 | - **PartialContainer**: Similar to a Container but allows services to be registered without defining all dependencies upfront. Unlike a regular Container, it does not support retrieving services directly. 85 | - **Service**: Any value or instance provided by the Container. 86 | - **Token**: A unique identifier for each service, used for registration and retrieval within the Container. 87 | - **InjectableFunction**: Functions that return service instances. They can include dependencies which are injected when the service is requested. 88 | - **InjectableClass**: Classes that can be instantiated by the Container. Dependencies should be specified in a static "dependencies" field to enable proper injection. 89 | 90 | ### API Reference 91 | 92 | For comprehensive documentation of all ts-inject features and APIs, please refer to the [API Reference](https://snapchat.github.io/ts-inject/). 93 | 94 | ## Contributing 95 | 96 | [Contributing guide](CONTRIBUTING.md). 97 | 98 | ## License 99 | 100 | `ts-inject` is published under [MIT license](LICENSE.md). 101 | 102 | ## Project Origins 103 | 104 | `ts-inject` originated as an internal project at [Snap Inc.](https://snap.com/), developed by [Weston Fribley](https://github.com/wfribley). Inspired by the principles of [typed-inject](https://github.com/nicojs/typed-inject), it was designed to address the limitations of existing dependency injection frameworks and improve typesafe dependency resolution in TypeScript. Initially aimed at enhancing [CameraKit](https://www.npmjs.com/package/@snap/camera-kit)'s codebase, its success led to its adoption across various teams at [Snap Inc.](https://snap.com/), and now it has evolved into an open-source project to benefit the wider TypeScript community. 105 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { JestConfigWithTsJest } from "ts-jest"; 2 | 3 | const config: JestConfigWithTsJest = { 4 | preset: "ts-jest", 5 | testEnvironment: "node", 6 | transform: { 7 | "^.+\\.tsx?$": [ 8 | "ts-jest", 9 | { 10 | tsconfig: "tsconfig.jest.json", 11 | }, 12 | ], 13 | }, 14 | collectCoverage: true, 15 | coverageReporters: ["text"], 16 | coveragePathIgnorePatterns: ["node_modules"], 17 | }; 18 | 19 | export default config; 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@snap/ts-inject", 3 | "version": "0.3.2", 4 | "description": "100% typesafe dependency injection framework for TypeScript projects", 5 | "license": "MIT", 6 | "author": "Snap Inc.", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/Snapchat/ts-inject.git" 10 | }, 11 | "homepage": "https://snapchat.github.io/ts-inject/", 12 | "main": "./dist/cjs/index.js", 13 | "module": "./dist/esm/index.js", 14 | "types": "./dist/types/index.d.ts", 15 | "exports": { 16 | "types": "./dist/types/index.d.ts", 17 | "node": "./dist/cjs/index.js", 18 | "require": "./dist/cjs/index.js", 19 | "default": "./dist/esm/index.js" 20 | }, 21 | "sideEffects": false, 22 | "scripts": { 23 | "styleguide": "npm run lint && npm run format:check", 24 | "styleguide:fix": "npm run lint:fix && npm run format:fix", 25 | "lint:fix": "npm run lint --fix", 26 | "lint": "eslint --ext .ts ./src", 27 | "format:check": "prettier -l *", 28 | "format:fix": "npm run format:check -- --write || exit 0", 29 | "test": "jest", 30 | "test:watch": "jest --clearCache && jest --watch", 31 | "compile": "tsc -b ./tsconfig.cjs.json ./tsconfig.esm.json ./tsconfig.types.json", 32 | "docs": "typedoc src/index.ts", 33 | "build": "rm -rf dist && rm -rf docs && npm run compile && npm run docs" 34 | }, 35 | "files": [ 36 | "docs", 37 | "dist" 38 | ], 39 | "keywords": [ 40 | "TypeScript", 41 | "typesafe", 42 | "Dependency Injection", 43 | "DI", 44 | "Inversion of Control", 45 | "IoC", 46 | "Snap", 47 | "Snapchat" 48 | ], 49 | "devDependencies": { 50 | "@types/jest": "^29.5.12", 51 | "@typescript-eslint/eslint-plugin": "^7.16.1", 52 | "@typescript-eslint/parser": "^7.16.1", 53 | "eslint": "^8.56.0", 54 | "eslint-plugin-import": "^2.29.1", 55 | "eslint-plugin-prefer-arrow": "1.2.3", 56 | "jest": "^29.7.0", 57 | "prettier": "^3.3.3", 58 | "ts-jest": "^29.2.3", 59 | "ts-node": "^10.9.2", 60 | "typedoc": "^0.26.4", 61 | "typescript": "^5.5.3" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Container.ts: -------------------------------------------------------------------------------- 1 | import type { Memoized } from "./memoize"; 2 | import { isMemoized, memoize } from "./memoize"; 3 | import { PartialContainer } from "./PartialContainer"; 4 | import type { AddService, AddServices, InjectableClass, InjectableFunction, TokenType, ValidTokens } from "./types"; 5 | import { ClassInjectable, ConcatInjectable, Injectable } from "./Injectable"; 6 | import { entries } from "./entries"; 7 | 8 | type MaybeMemoizedFactories = { 9 | [K in keyof Services]: (() => Services[K]) | Memoized<() => Services[K]>; 10 | }; 11 | 12 | type Factories = { 13 | [K in keyof Services]: Memoized<() => Services[K]>; 14 | }; 15 | 16 | /** 17 | * A special token used to resolve the entire container as a dependency. 18 | * This can be utilized when a service needs access to the container itself, 19 | * allowing for dynamic retrieval of other services. 20 | * 21 | * @example 22 | * 23 | * ```ts 24 | * const initial = Container.providesValue("value", 1); 25 | * const extended = initial.provides( 26 | * Injectable("service", [CONTAINER], (container: typeof initial) => { 27 | * return container.get("value") + 1; 28 | * }) 29 | * ); 30 | * 31 | * const result = extended.get("service"); // 2 32 | * ``` 33 | */ 34 | export const CONTAINER = "$container"; 35 | export type ContainerToken = typeof CONTAINER; 36 | 37 | type ArrayElement = A extends readonly (infer T)[] ? T : never; 38 | 39 | /** 40 | * Represents the dependency injection container that manages the registration, 41 | * creation, and retrieval of services. The Container class is central to 42 | * the dependency injection process, facilitating typesafe injection and 43 | * retrieval of services based on tokens. 44 | * 45 | * @example 46 | * 47 | * ```ts 48 | * const fooFactory = Injectable('Foo', () => new Foo()) 49 | * const barFactory = Injectable('Bar', ['Foo'] as const, (foo: Foo) => new Bar(foo)) 50 | * const container = Container.provides(fooFactory).provides(barFactory) 51 | * 52 | * const bar = container.get('Bar') 53 | * ``` 54 | */ 55 | export class Container { 56 | /** 57 | * Creates a new [Container] by providing a [PartialContainer] that has no dependencies. 58 | * 59 | * @example 60 | * ```ts 61 | * // Register a single service 62 | * const container = Container.provides(Injectable('Logger', () => new Logger())); 63 | * 64 | * // Extend container with another container or partial container 65 | * const extendedContainer = Container.provide(existingContainer); 66 | * ``` 67 | */ 68 | static provides(container: PartialContainer | Container): Container; 69 | 70 | /** 71 | * Creates a new [Container] by providing a Service that has no dependencies. 72 | * 73 | * @example 74 | * ```ts 75 | * // Register a single service with no dependencies 76 | * const container = Container.provides(Injectable('Logger', () => new Logger())); 77 | * ``` 78 | */ 79 | static provides( 80 | fn: InjectableFunction<{}, [], Token, Service> 81 | ): Container>; 82 | 83 | static provides( 84 | fnOrContainer: InjectableFunction<{}, [], TokenType, any> | PartialContainer | Container 85 | ): Container { 86 | // Although the `provides` method has overloads that match both members of the union type separately, it does 87 | // not match the union type itself, so the compiler forces us to branch and handle each type within the union 88 | // separately. (Maybe in the future the compiler will decide to infer this, but for now this is necessary.) 89 | if (fnOrContainer instanceof PartialContainer) return new Container({}).provides(fnOrContainer); 90 | if (fnOrContainer instanceof Container) return new Container({}).provides(fnOrContainer); 91 | return new Container({}).provides(fnOrContainer); 92 | } 93 | 94 | /** 95 | * Registers a static value as a service in a new Container. Ideal for services that don't require 96 | * instantiation or dependencies. 97 | 98 | * NOTE: This method acts as a syntactic shortcut, essentially registering a factory function that 99 | * directly returns the provided value. 100 | * 101 | * @example 102 | * ```ts 103 | * // Registering an instance of a class 104 | * const logger = new Logger(); 105 | * const container = Container.providesValue('Logger', logger); 106 | * 107 | * // This is effectively a shortcut for the following, where an Injectable is explicitly created 108 | * const container2 = Container.provides(Injectable('Logger', () => logger); 109 | * ``` 110 | * 111 | * @param token A unique Token identifying the service within the container. This token is used to retrieve the value. 112 | * @param value The value or instance to register as a service within the container. This can be of any type. 113 | * @returns A new Container instance with the specified service registered. 114 | */ 115 | static providesValue( 116 | token: Token, 117 | value: Service 118 | ): Container> { 119 | return new Container({}).providesValue(token, value); 120 | } 121 | 122 | /** 123 | * Creates a new Container from a plain object containing service definitions. 124 | * Each property of the object is treated as a unique token, 125 | * and its corresponding value is registered in the Container as a service under that token. 126 | * This method offers a convenient way to quickly bootstrap a container with predefined services. 127 | * 128 | * @example 129 | * ```ts 130 | * // Creating a container with simple value services 131 | * const container = Container.fromObject({ foo: 1, bar: 'baz' }); 132 | * 133 | * // Retrieving services from the container 134 | * console.log(container.get('foo')); // prints 1 135 | * console.log(container.get('bar')); // prints 'baz' 136 | * ``` 137 | * 138 | * In this example, `container` is of type `Container<{ foo: number, bar: string }>` indicating 139 | * that it holds services under the tokens 'foo' and 'bar' with corresponding types. 140 | * 141 | * @param services A plain object where each property (token) maps to a service value. This object 142 | * defines the initial set of services to be contained within the new Container instance. 143 | * @returns A new Container instance populated with the provided services. 144 | */ 145 | static fromObject(services: Services): Container { 146 | return entries(services).reduce( 147 | (container, [token, value]) => container.providesValue(token, value), 148 | new Container({}) 149 | ) as Container; 150 | } 151 | 152 | // this is public on purpose; if the field is declared as private generated *.d.ts files do not include the field type 153 | // which makes typescript compiler behave differently when resolving container types; e.g. it becomes impossible to 154 | // assign a container of type Container<{ a: number, b: string }> to a variable of type Container<{ a: number }>. 155 | readonly factories: Readonly>; 156 | 157 | constructor(factories: MaybeMemoizedFactories) { 158 | const memoizedFactories = {} as Factories; 159 | for (const k in factories) { 160 | const fn = factories[k]; 161 | if (isMemoized(fn)) { 162 | memoizedFactories[k] = fn; 163 | // to allow overriding values in the container we replace the factory's reference to the container with the 164 | // newly created one, this makes sure that overrides are taken into account when resolving the service's 165 | // dependencies. 166 | fn.thisArg = this; 167 | } else { 168 | memoizedFactories[k] = memoize(this, fn); 169 | } 170 | } 171 | this.factories = memoizedFactories; 172 | } 173 | 174 | /** 175 | * Creates a copy of this Container, optionally scoping specified services to the new copy. 176 | * Unspecified services are shared between the original and copied containers, 177 | * while factory functions for scoped services are re-invoked upon service resolution in the new container. 178 | * 179 | * This can be useful, for example, if different parts of an application wish to use the same Service interface, 180 | * but do not want to share a reference to same Service instance. 181 | * 182 | * Consider an example where we have a `UserListService` that manages a list of users. 183 | * If our application needs to display two user lists that can be edited independently 184 | * (e.g., in separate components or pages), it would be beneficial to create a distinct Container 185 | * for each list component. By scoping the `UserListService` to each Container, 186 | * we ensure that each component receives its own independent copy of the service. 187 | * This setup allows for independent edits to each user list without any overlap or 188 | * interference between the two components. 189 | * 190 | * @example 191 | * ```ts 192 | * // Create the original container and provide the UserListService 193 | * const originalContainer = Container.provides(Injectable('UserListService', () => new UserListService())); 194 | * 195 | * // Create a new Container copy with UserListService scoped, allowing for independent user lists 196 | * const newListContainer = originalContainer.copy(['UserListService']); 197 | * 198 | * // Each Container now manages its own independent UserListService service instance 199 | * ``` 200 | * 201 | * @param scopedServices An optional list of tokens for Services to be scoped to the new Container copy. Services 202 | * not specified will be shared with the original Container, while specified ones will be re-instantiated in the 203 | * new Container. 204 | * @returns A new Container copy that shares the original's services, with specified services scoped as unique 205 | * instances to the new Container. 206 | */ 207 | copy(scopedServices?: Tokens): Container { 208 | const factories: MaybeMemoizedFactories = { ...this.factories }; 209 | 210 | // We "un-memoize" scoped Service InjectableFunctions so they will create a new copy of their Service when 211 | // provided by the new Container – we re-memoize them so the new Container will itself only create one Service 212 | // instance. 213 | (scopedServices || []).forEach((token: keyof Services) => { 214 | factories[token] = this.factories[token].delegate; 215 | }); 216 | return new Container(factories); 217 | } 218 | 219 | /** 220 | * Retrieves a reference to this Container. 221 | * 222 | * @param token The {@link CONTAINER} token. 223 | * @returns This Container. 224 | */ 225 | get(token: ContainerToken): this; 226 | 227 | /** 228 | * Retrieves a Service from the Container by its token. 229 | * On first request, the service's factory function is invoked and the result is memoized for future requests, 230 | * ensuring singleton behavior. 231 | * 232 | * @param token A unique token corresponding to a Service 233 | * @returns A Service corresponding to the given Token. 234 | */ 235 | get(token: Token): Services[Token]; 236 | 237 | get(token: ContainerToken | keyof Services): this | Services[keyof Services] { 238 | if (token === CONTAINER) return this; 239 | const factory = this.factories[token]; 240 | if (!factory) { 241 | throw new Error( 242 | `[Container::get] Could not find Service for Token "${String(token)}". This should've caused a ` + 243 | "compile-time error. If the Token is 'undefined', check all your calls to the Injectable " + 244 | "function. Make sure you define dependencies using string literals or string constants that are " + 245 | "definitely initialized before the call to Injectable." 246 | ); 247 | } 248 | return factory(); 249 | } 250 | 251 | /** 252 | * Runs the factory functions for all services listed in the provided {@link PartialContainer}, 253 | * along with their dependencies that are registered within *this* container. 254 | * 255 | * This method is particularly useful for preemptively initializing services that require setup before use. 256 | * It ensures that services are ready when needed without waiting for a lazy instantiation. 257 | * 258 | * **Note**: This method does not add new services to the container. 259 | * 260 | * @example 261 | * ```ts 262 | * // Create initializers for caching and reporting setup that depend on a request service 263 | * const initializers = new PartialContainer({}) 264 | * .provides(Injectable("initCache", ["request"], (request: Request) => fetchAndPopulateCache(request))) 265 | * .provides(Injectable("setupReporter", ["request"], (request: Request) => setupReporter(request))); 266 | * 267 | * // Setup the main container with a request service and run the initializers 268 | * const container = Container 269 | * .provides(Injectable("request", () => (url: string) => fetch(url))) 270 | * .run(initializers); 271 | * 272 | * // At this point, `initCache` and `setupReporter` have been executed using the `request` service. 273 | * // And the `request` service itself has also been initialized within the `container`. 274 | * ``` 275 | * @param container The {@link PartialContainer} specifying which services to initialize. 276 | * @returns The current container unchanged, with dependencies of the services listed 277 | * in the provided {@link PartialContainer} initialized as needed. 278 | */ 279 | run( 280 | // FullfilledDependencies is assignable to Dependencies -- by specifying Container as the 281 | // `this` type, we ensure this Container can provide all the Dependencies required by the PartialContainer. 282 | this: Container, 283 | container: PartialContainer 284 | ): this; 285 | 286 | /** 287 | * Runs the factory function for a specified service provided by {@link InjectableFunction}, 288 | * along with its dependencies that are registered within *this* container. 289 | * 290 | * This method is particularly useful for services that need to be set up before they are used. It ensures that 291 | * the service is ready when needed, without relying on lazy instantiation. 292 | * 293 | * **Note**: This method does not add new services to the container. 294 | * 295 | * @example 296 | * ```ts 297 | * // Setup a container with a request service and directly run the `initCache` service 298 | * const container = Container 299 | * .provides(Injectable("request", () => (url: string) => fetch(url))) 300 | * .run(Injectable("initCache", ["request"], (request: Request) => fetchAndPopulateCache(request))); 301 | * 302 | * // At this point, `initCache` has been executed using the `request` service. 303 | * // And the `request` service itself has also been initialized. 304 | * ``` 305 | * 306 | * @param fn The {@link InjectableFunction} specifying the service to initialize. 307 | * @returns The current container unchanged, with dependencies of the provided {@link InjectableFunction} 308 | * initialized as needed. 309 | */ 310 | run[], Service>( 311 | fn: InjectableFunction 312 | ): this; 313 | 314 | run[], Service, AdditionalServices>( 315 | fnOrContainer: InjectableFunction | PartialContainer 316 | ): this { 317 | if (fnOrContainer instanceof PartialContainer) { 318 | const runnableContainer = this.provides(fnOrContainer); 319 | for (const token of fnOrContainer.getTokens()) { 320 | runnableContainer.get(token); 321 | } 322 | } else { 323 | this.provides(fnOrContainer).get(fnOrContainer.token); 324 | } 325 | return this; 326 | } 327 | 328 | /** 329 | * Merges additional services from a given `PartialContainer` into this container, 330 | * creating a new `Container` instance. Services defined in the `PartialContainer` take precedence 331 | * in the event of token conflicts, meaning any service in the `PartialContainer` with the same token 332 | * as one in this container will override the existing service. 333 | * 334 | * If the same `PartialContainer` is provided to multiple containers, each resulting container will have its own 335 | * independent instance of the services defined in the `PartialContainer`, ensuring no shared state between them. 336 | * 337 | * @param container The `PartialContainer` that provides the additional services to be merged into this container. 338 | * This container defines services and their dependencies that are to be integrated. 339 | * @returns A new `Container` instance that combines the services of this container with those from the provided 340 | * `PartialContainer`, with services from the `PartialContainer` taking precedence in case of conflicts. 341 | */ 342 | provides( 343 | // FullfilledDependencies is assignable to Dependencies -- by specifying Container as the 344 | // `this` type, we ensure this Container can provide all the Dependencies required by the PartialContainer. 345 | this: Container, 346 | container: PartialContainer 347 | ): Container>; 348 | 349 | /** 350 | * Merges services from another `Container` into this container, creating a new `Container` instance. 351 | * Services from the provided `Container` take precedence in the event of token conflicts. 352 | * 353 | * Importantly, services from the provided `Container` are shared between the original (source) container 354 | * and the new (destination) container created by this method. This means that both containers will reference 355 | * the same service instances, ensuring consistency but not isolation. 356 | * 357 | * If isolation is required (i.e., separate instances of the services in different containers), the source 358 | * container should be copied before being passed to this method. This ensures that new instances of the 359 | * services are created in the new container, avoiding shared state issues. 360 | * 361 | * @param container The `Container` that provides the additional services to be merged. 362 | * @returns A new `Container` instance that combines services from this container with those from the 363 | * provided container, with services from the provided container taking precedence in case of conflicts. 364 | */ 365 | provides( 366 | container: Container 367 | ): Container>; 368 | 369 | /** 370 | * Registers a new service in this Container using an `InjectableFunction`. This function defines how the service 371 | * is created, including its dependencies and the token under which it will be registered. When called, this method 372 | * adds the service to the container, ready to be retrieved via its token. 373 | * 374 | * The `InjectableFunction` must specify: 375 | * - A unique `Token` identifying the service. 376 | * - A list of `Tokens` representing the dependencies needed to create the service. 377 | * 378 | * This method ensures type safety by verifying that all required dependencies are available in the container 379 | * and match the expected types. If a dependency is missing or a type mismatch occurs, a compiler error is raised, 380 | * preventing runtime errors and ensuring reliable service creation. 381 | * 382 | * @param fn The `InjectableFunction` that constructs the service. It should take required dependencies as arguments 383 | * and return the newly created service. 384 | * @returns A new `Container` instance containing the added service, allowing chaining of multiple `provides` calls. 385 | */ 386 | provides[], Service>( 387 | fn: InjectableFunction 388 | ): Container>; 389 | 390 | provides[], Service, AdditionalServices>( 391 | fnOrContainer: 392 | | InjectableFunction 393 | | PartialContainer 394 | | Container 395 | ): Container { 396 | if (fnOrContainer instanceof PartialContainer || fnOrContainer instanceof Container) { 397 | const factories = 398 | fnOrContainer instanceof PartialContainer ? fnOrContainer.getFactories(this) : fnOrContainer.factories; 399 | // Safety: `this.factories` and `factories` are both properly type checked, so merging them produces 400 | // a Factories object with keys from both Services and AdditionalServices. The compiler is unable to 401 | // infer that Factories & Factories == Factories, so the cast is required. 402 | return new Container({ 403 | ...this.factories, 404 | ...factories, 405 | } as unknown as MaybeMemoizedFactories>); 406 | } 407 | return this.providesService(fnOrContainer); 408 | } 409 | 410 | /** 411 | * Registers a service in the container using a class constructor, simplifying the service creation process. 412 | * 413 | * This method is particularly useful when the service creation logic can be encapsulated within a class 414 | * constructor. 415 | * 416 | * @param token A unique Token used to identify and retrieve the service from the container. 417 | * @param cls A class with a constructor that takes dependencies as arguments and a static `dependencies` field 418 | * specifying these dependencies. 419 | * @returns A new Container instance containing the newly created service, allowing for method chaining. 420 | */ 421 | providesClass = []>( 422 | token: Token, 423 | cls: InjectableClass 424 | ): Container> => 425 | this.providesService(ClassInjectable(token, cls)) as Container>; 426 | 427 | /** 428 | * Registers a static value as a service in the container. This method is ideal for services that do not 429 | * require dynamic instantiation and can be provided directly as they are. 430 | * 431 | * @param token A unique Token used to identify and retrieve the service from the container. 432 | * @param value The actual value to register as a service. This could be anything from a simple data object, 433 | * a configuration, or a pre-instantiated service object. 434 | * @returns A new Container instance that includes the provided service, allowing for chaining additional 435 | * `provides` calls. 436 | */ 437 | providesValue = ( 438 | token: Token, 439 | value: Service 440 | ): Container> => this.providesService(Injectable(token, [], () => value)); 441 | 442 | /** 443 | * Appends a value to the array associated with a specified token in the current Container, then returns 444 | * the new Container with the updated value. This method is applicable under the following conditions: 445 | * 1. The Container already contains an array associated with the given token. 446 | * 2. The type of the items in the array matches the type of the value being appended. 447 | * 448 | * ```ts 449 | * const container = Container.fromObject({ services: [1, 2, 3] as number[] }); 450 | * const newContainer = container.appendValue('services', 4); 451 | * console.log(newContainer.get('services')); // prints [1, 2, 3, 4]; 452 | * ``` 453 | * 454 | * @param token - A unique Token which will correspond to the previously defined typed array. 455 | * @param value - A value to append to the array. 456 | * @returns The updated Container with the appended value in the specified array. 457 | */ 458 | appendValue = >( 459 | token: Token, 460 | value: Service 461 | ): Container => this.providesService(ConcatInjectable(token, () => value)) as Container; 462 | 463 | /** 464 | * Appends an injectable class factory to the array associated with a specified token in the current Container, 465 | * then returns the new Container with the updated value. This method is applicable under the following conditions: 466 | * 1. The Container already contains an array associated with the given token. 467 | * 2. The type of the items in the array matches the type of the value being appended. 468 | * 469 | * ```ts 470 | * const container = Container.fromObject({ services: [] as Service[] }); 471 | * const newContainer = container.appendClass('services', Service); 472 | * console.log(newContainer.get('services').length); // prints 1; 473 | * 474 | * @param token - A unique Token which will correspond to the previously defined typed array. 475 | * @param cls - A class with a constructor that takes dependencies as arguments, which returns the Service. 476 | * @returns The updated Container with the new service instance appended to the specified array. 477 | */ 478 | appendClass = < 479 | Token extends keyof Services, 480 | Tokens extends readonly ValidTokens[], 481 | Service extends ArrayElement, 482 | >( 483 | token: Token, 484 | cls: InjectableClass 485 | ): Container => 486 | this.providesService( 487 | ConcatInjectable(token, () => this.providesClass(token, cls).get(token)) 488 | ) as Container; 489 | 490 | /** 491 | * Appends a new service instance to an existing array within the container using an `InjectableFunction`. 492 | * 493 | * @example 494 | * ```ts 495 | * // Assume there's a container with an array ready to hold service instances 496 | * const container = Container.fromObject({ services: [] as Service[] }); 497 | * // Append a new Service instance to the 'services' array using a factory function 498 | * const newContainer = container.append(Injectable('services', () => new Service())); 499 | * // Retrieve the services array to see the added Service instance 500 | * console.log(newContainer.get('services').length); // prints 1; 501 | * ``` 502 | * 503 | * @param fn - An injectable function that returns the Service. 504 | * @returns The updated Container, now including the new service instance appended to the array 505 | * specified by the token. 506 | */ 507 | append = < 508 | Token extends keyof Services, 509 | Tokens extends readonly ValidTokens[], 510 | Service extends ArrayElement, 511 | >( 512 | fn: InjectableFunction 513 | ): Container => 514 | this.providesService( 515 | ConcatInjectable(fn.token, () => this.providesService(fn).get(fn.token)) 516 | ) as Container; 517 | 518 | private providesService< 519 | Token extends TokenType, 520 | Tokens extends readonly ValidTokens[], 521 | Service, 522 | Dependencies, 523 | >(fn: InjectableFunction): Container> { 524 | const token = fn.token; 525 | const dependencies: readonly any[] = fn.dependencies; 526 | // If the service depends on itself, e.g. in the multi-binding case, where we call append multiple times with 527 | // the same token, we always must resolve the dependency using the parent container to avoid infinite loop. 528 | const getFromParent = dependencies.indexOf(token) === -1 ? undefined : () => this.get(token as any); 529 | const factory = memoize(this, function (this: Container) { 530 | // Safety: getFromParent is defined if the token is in the dependencies list, so it is safe to call it. 531 | return fn(...(dependencies.map((t) => (t === token ? getFromParent!() : this.get(t))) as any)); 532 | }); 533 | // Safety: `token` and `factory` are properly type checked, so extending `this.factories` produces a 534 | // MaybeMemoizedFactories object with the expected set of services – but when using the spread operation to 535 | // merge two objects, the compiler widens the Token type to string. So we must re-narrow via casting. 536 | const factories = { ...this.factories, [token]: factory }; 537 | return new Container(factories) as Container>; 538 | } 539 | } 540 | -------------------------------------------------------------------------------- /src/Injectable.ts: -------------------------------------------------------------------------------- 1 | import type { InjectableClass, InjectableFunction, ServicesFromTokenizedParams, TokenType } from "./types"; 2 | 3 | /** 4 | * Creates an Injectable factory function designed for services without dependencies. 5 | * This is useful for simple services or values that don't depend on other parts of the system. 6 | * 7 | * @example 8 | * ```ts 9 | * const container = Container.provides(Injectable("MyService", () => new MyService())); 10 | * 11 | * const myService = container.get("MyService"); 12 | * ``` 13 | * 14 | * @param token A unique Token identifying the Service within the container. This token 15 | * is used to retrieve the instance from the container. 16 | * @param fn A zero-argument function that initializes and returns the Service instance. 17 | * This can be any class instance, primitive, or complex value meant to be managed 18 | * within the DI container. 19 | */ 20 | export function Injectable( 21 | token: Token, 22 | fn: () => Service 23 | ): InjectableFunction; 24 | 25 | /** 26 | * Creates an Injectable factory function that requires dependencies. 27 | * 28 | * The dependencies are specified as tokens, and the factory function 29 | * will receive these dependencies as arguments in the order they are listed. 30 | * 31 | * **Important:** This function requires **TypeScript 5 or later** due to the use of `const` type parameters. 32 | * Users on TypeScript 4 and earlier must use {@link InjectableCompat} instead. 33 | * 34 | * @example 35 | * ```ts 36 | * const dependencyB = 'DependencyB'; 37 | * const container = Container 38 | * .providesValue("DependencyA", new A()) 39 | * .providesValue("DependencyB", new B()) 40 | * .provides(Injectable( 41 | * "MyService", 42 | * ["DependencyA", dependencyB] as const, // "as const" can be omitted in TypeScript 5 and later 43 | * (a: A, b: B) => new MyService(a, b), 44 | * ) 45 | * ) 46 | * 47 | * const myService = container.get("MyService"); 48 | * ``` 49 | * 50 | * @param token A unique Token identifying the Service within the container. 51 | * @param dependencies A *readonly* array of Tokens representing the dependencies required by the factory function. 52 | * These will be resolved by the container and provided as arguments to the factory function. 53 | * @param fn A factory function whose parameters match the dependencies. This function should initialize and 54 | * return an instance of the Service. The types and number of its parameters must exactly match the dependencies. 55 | */ 56 | export function Injectable< 57 | Token extends TokenType, 58 | const Tokens extends readonly TokenType[], 59 | Params extends readonly any[], 60 | Service, 61 | >( 62 | token: Token, 63 | dependencies: Tokens, 64 | // The function arity (number of arguments) must match the number of dependencies specified – if they don't, we'll 65 | // force a compiler error by saying the arguments should be `void[]`. We'll also throw at runtime, so the return 66 | // type will be `never`. 67 | fn: (...args: Tokens["length"] extends Params["length"] ? Params : void[]) => Service 68 | ): Tokens["length"] extends Params["length"] 69 | ? InjectableFunction, Tokens, Token, Service> 70 | : never; 71 | 72 | export function Injectable( 73 | token: TokenType, 74 | dependenciesOrFn?: readonly TokenType[] | (() => any), 75 | maybeFn?: (...args: any[]) => any 76 | ): InjectableFunction { 77 | const dependencies: TokenType[] = Array.isArray(dependenciesOrFn) ? dependenciesOrFn : []; 78 | const fn = typeof dependenciesOrFn === "function" ? dependenciesOrFn : maybeFn; 79 | 80 | if (!fn) { 81 | throw new TypeError( 82 | "[Injectable] Received invalid arguments. The factory function must be either the second " + "or third argument." 83 | ); 84 | } 85 | 86 | if (fn.length !== dependencies.length) { 87 | throw new TypeError( 88 | "[Injectable] Function arity does not match the number of dependencies. Function has arity " + 89 | `${fn.length}, but ${dependencies.length} dependencies were specified.` + 90 | `\nDependencies: ${JSON.stringify(dependencies)}` 91 | ); 92 | } 93 | 94 | const factory = (...args: any[]) => fn(...args); 95 | factory.token = token; 96 | factory.dependencies = dependencies; 97 | return factory; 98 | } 99 | 100 | /** 101 | * A compatibility version of {@link Injectable} for TypeScript 4 and earlier users. 102 | * This function behaves identically to {@link Injectable} but requires the use of `as const` on the dependencies array. 103 | * 104 | * @deprecated Use {@link Injectable} instead. This function is provided for compatibility with TypeScript 4 105 | * and earlier versions and will be removed in future releases. 106 | * 107 | * @see {@link Injectable} for detailed usage instructions and examples. 108 | */ 109 | export function InjectableCompat< 110 | Token extends TokenType, 111 | Tokens extends readonly TokenType[], 112 | Params extends readonly any[], 113 | Service, 114 | >( 115 | token: Token, 116 | dependencies: Tokens, 117 | fn: (...args: Tokens["length"] extends Params["length"] ? Params : void[]) => Service 118 | ): ReturnType { 119 | return Injectable(token, dependencies, fn); 120 | } 121 | 122 | /** 123 | * Creates an Injectable factory function for an InjectableClass. 124 | * 125 | * @example 126 | * ```ts 127 | * class Logger { 128 | * static dependencies = ["config"] as const; 129 | * constructor(private config: string) {} 130 | * public print() { 131 | * console.log(this.config); 132 | * } 133 | * } 134 | * 135 | * const container = Container 136 | * .providesValue("config", "value") 137 | * .provides(ClassInjectable("logger", Logger)); 138 | * 139 | * container.get("logger").print(); // prints "value" 140 | * ``` 141 | * 142 | * It is recommended to use the `Container.provideClass()` method. The example above is equivalent to: 143 | * ```ts 144 | * const container = Container 145 | * .providesValue("config", "value") 146 | * .providesClass("logger", Logger); 147 | * container.get("logger").print(); // prints "value" 148 | * ``` 149 | * 150 | * @param token Token identifying the Service. 151 | * @param cls InjectableClass to instantiate. 152 | */ 153 | export function ClassInjectable< 154 | Class extends InjectableClass, 155 | Dependencies extends ConstructorParameters, 156 | Token extends TokenType, 157 | Tokens extends Class["dependencies"], 158 | >( 159 | token: Token, 160 | cls: Class 161 | ): InjectableFunction, Tokens, Token, ConstructorReturnType>; 162 | 163 | export function ClassInjectable( 164 | token: TokenType, 165 | cls: InjectableClass 166 | ): InjectableFunction { 167 | const factory = (...args: any[]) => new cls(...args); 168 | factory.token = token; 169 | factory.dependencies = cls.dependencies; 170 | return factory; 171 | } 172 | 173 | /** 174 | * Creates an Injectable factory function without dependencies that appends a Service 175 | * to an existing array of Services of the same type. Useful for dynamically expanding 176 | * service collections without altering original service tokens or factories. 177 | * 178 | * @example 179 | * ```ts 180 | * const container = Container 181 | * .providesValue("values", [1]) // Initially provide an array with one value 182 | * .provides(ConcatInjectable("values", () => 2)); // Append another value to the array 183 | * 184 | * const result = container.get("values"); // Results in [1, 2] 185 | * ``` 186 | * 187 | * In this context, `ConcatInjectable("values", () => 2)` acts as a simplified form of 188 | * `Injectable("values", ["values"], (values: number[]) => [...values, 2])`, 189 | * directly appending a new value to the "values" service array without the need for explicit array manipulation. 190 | * 191 | * @param token Token identifying an existing Service array to which the new Service will be appended. 192 | * @param fn A no-argument function that returns the service to be appended. 193 | */ 194 | export function ConcatInjectable( 195 | token: Token, 196 | fn: () => Service 197 | ): InjectableFunction<{ [T in keyof Token]: Service[] }, [], Token, Service[]>; 198 | 199 | /** 200 | * Creates an Injectable factory function with dependencies that appends a Service 201 | * to an existing array of Services of the same type. This variant supports services 202 | * that require other services to be instantiated, allowing for more complex setups. 203 | * 204 | * @example 205 | * ```ts 206 | * const container = Container 207 | * .providesValue("two", 2) 208 | * .providesValue("values", [1]) // Initially provide an array with one value 209 | * .provides(ConcatInjectable("values", ["two"] as const, (two: number) => two)); // Append another value to the array 210 | * 211 | * const result = container.get("values"); // [1, 2] 212 | * ``` 213 | * 214 | * @param token Token identifying an existing Service array to append the new Service to. 215 | * @param dependencies Read-only list of Tokens for dependencies required by the factory function. 216 | * @param fn Factory function returning the Service to append. 217 | * The types and number of its parameters must exactly match the dependencies. 218 | */ 219 | export function ConcatInjectable< 220 | Token extends TokenType, 221 | const Tokens extends readonly TokenType[], 222 | Params extends readonly any[], 223 | Service, 224 | >( 225 | token: Token, 226 | dependencies: Tokens, 227 | fn: (...args: Tokens["length"] extends Params["length"] ? Params : void[]) => Service 228 | ): InjectableFunction, Tokens, Token, Service[]>; 229 | 230 | export function ConcatInjectable( 231 | token: TokenType, 232 | dependenciesOrFn?: readonly TokenType[] | (() => any), 233 | maybeFn?: (...args: any[]) => any 234 | ): InjectableFunction { 235 | const dependencies: TokenType[] = Array.isArray(dependenciesOrFn) ? dependenciesOrFn : []; 236 | const fn = typeof dependenciesOrFn === "function" ? dependenciesOrFn : maybeFn; 237 | 238 | if (!fn) { 239 | throw new TypeError( 240 | "[ConcatInjectable] Received invalid arguments. The factory function must be either the second " + 241 | "or third argument." 242 | ); 243 | } 244 | 245 | if (fn.length !== dependencies.length) { 246 | throw new TypeError( 247 | "[Injectable] Function arity does not match the number of dependencies. Function has arity " + 248 | `${fn.length}, but ${dependencies.length} dependencies were specified.` + 249 | `\nDependencies: ${JSON.stringify(dependencies)}` 250 | ); 251 | } 252 | 253 | const factory = (array: any[], ...args: any[]) => { 254 | return array.concat(fn(...args)); 255 | }; 256 | factory.token = token; 257 | factory.dependencies = [token, ...dependencies]; 258 | return factory; 259 | } 260 | 261 | export type ConstructorReturnType = T extends new (...args: any) => infer C ? C : any; 262 | -------------------------------------------------------------------------------- /src/PartialContainer.ts: -------------------------------------------------------------------------------- 1 | import { entries } from "./entries"; 2 | import type { Memoized } from "./memoize"; 3 | import { memoize } from "./memoize"; 4 | import type { Container } from "./Container"; 5 | import type { 6 | AddService, 7 | InjectableClass, 8 | InjectableFunction, 9 | ServicesFromTokenizedParams, 10 | TokenType, 11 | ValidTokens, 12 | } from "./types"; 13 | import type { ConstructorReturnType } from "./Injectable"; 14 | import { ClassInjectable, Injectable } from "./Injectable"; 15 | 16 | // Using a conditional type forces TS language services to evaluate the type -- so when showing e.g. type hints, we 17 | // will see the mapped type instead of the AddDependencies type alias. This produces better hints. 18 | type AddDependencies = ParentDependencies extends any 19 | ? // A mapped type produces better, more concise type hints than an intersection type. 20 | { 21 | [K in keyof ParentDependencies | keyof Dependencies]: K extends keyof ParentDependencies 22 | ? ParentDependencies[K] 23 | : K extends keyof Dependencies 24 | ? Dependencies[K] 25 | : never; 26 | } 27 | : never; 28 | 29 | type ExcludeKey = T extends any ? { [K in Exclude]: T[K] } : never; 30 | 31 | type PartialInjectableFunction< 32 | Params extends readonly any[], 33 | Tokens extends readonly TokenType[], 34 | Token extends TokenType, 35 | Service, 36 | > = { 37 | (...args: Params): Service; 38 | token: Token; 39 | dependencies: Tokens; 40 | }; 41 | 42 | type Injectables = { 43 | [K in keyof Services]: K extends TokenType 44 | ? InjectableFunction[], K, Services[K]> 45 | : never; 46 | }; 47 | 48 | type PartialContainerFactories = { 49 | [K in keyof Services]: Memoized<() => Services[K]>; 50 | }; 51 | 52 | /** 53 | * Similar to [Container], with the exception that Services may be provided to a PartialContainer which *does not* 54 | * contain all of that Services dependencies. 55 | * 56 | * For this to remain safe, Services can not be resolved by PartialContainer – it has no `get` method. 57 | * 58 | * Instead, the PartialContainer must be provided to a [Container] which *does* contain all the dependencies required 59 | * by all the Service in the PartialContainer. The resulting [Container] can then resolve these Services. 60 | * 61 | * PartialContainers are used to create a collection of Services which can then be provided via a simple one-line syntax 62 | * to an existing Container (which fulfills the collection's dependencies). It is an organizational tool, allowing 63 | * coherent groupings of Services to be defined in one place, then combined elsewhere to form a complete [Container]. 64 | * 65 | * Here's an example of PartialContainer usage: 66 | * ```ts 67 | * // We can provide fooFactory, even though the PartialContainer doesn't fulfill the Bar dependency. 68 | * const fooFactory = Injectable('Foo', ['Bar'] as const, (bar: Bar) => new Foo(bar)) 69 | * const partialContainer = new PartialContainer({}).provide(fooFactory) 70 | * 71 | * const barFactory = Injectable('Bar', () => new Bar()) 72 | * const dependenciesContainer = Container.provides(barFactory) 73 | * 74 | * const combinedContainer = dependenciesContainer.provides(partialContainer) 75 | * 76 | * // We can resolve Foo, because the combined container includes Bar, so all of Foo's dependencies are now met. 77 | * const foo = combinedContainer.get('Foo') 78 | * ``` 79 | */ 80 | export class PartialContainer { 81 | constructor(private readonly injectables: Injectables) {} 82 | 83 | /** 84 | * Create a new PartialContainer which provides a Service created by the given InjectableFunction. 85 | * 86 | * The InjectableFunction contains metadata specifying the Token by which the created Service will be known, as well 87 | * as an ordered list of Tokens to be resolved and provided to the InjectableFunction as arguments. 88 | * 89 | * The dependencies are allowed to be missing from the PartialContainer, but these dependencies are maintained as a 90 | * parameter of the returned PartialContainer. This allows `[Container.provides]` to type check the dependencies and 91 | * ensure they can be provided by the Container. 92 | * 93 | * @param fn A InjectableFunction, taking dependencies as arguments, which returns the Service. 94 | */ 95 | provides< 96 | AdditionalDependencies extends readonly any[], 97 | Tokens extends readonly TokenType[], 98 | Token extends TokenType, 99 | Service, 100 | >( 101 | fn: PartialInjectableFunction 102 | ): PartialContainer< 103 | AddService, 104 | // The dependencies of the new PartialContainer are the combined dependencies of this container and the 105 | // PartialInjectableFunction -- but we exclude any dependencies already provided by this container (i.e. this 106 | // container's Services) as well as the new Service being provided. 107 | ExcludeKey< 108 | AddDependencies, ServicesFromTokenizedParams>, 109 | keyof Services 110 | > 111 | > { 112 | return new PartialContainer({ ...this.injectables, [fn.token]: fn } as any); 113 | } 114 | 115 | /** 116 | * Create a new PartialContainer which provides the given value as a Service. 117 | * 118 | * Example: 119 | * ```ts 120 | * const partial = new PartialContainer({}).providesValue("value", 42); 121 | * const value = Container.provides(partial).get("value"); 122 | * console.log(value); // 42 123 | * ``` 124 | * 125 | * @param token the Token by which the value will be known. 126 | * @param value the value to be provided. 127 | */ 128 | providesValue = (token: Token, value: Service) => 129 | this.provides(Injectable(token, [], () => value)); 130 | 131 | /** 132 | * Create a new PartialContainer which provides the given class as a Service, all of the class's dependencies will be 133 | * resolved by the parent Container. 134 | * 135 | * Example: 136 | * ```ts 137 | * class Foo { 138 | * static dependencies = ['bar'] as const; 139 | * constructor(public bar: string) {} 140 | * } 141 | * 142 | * const partial = new PartialContainer({}).providesClass("foo", Foo); 143 | * const foo = Container.providesValue("bar", "bar value").provides(partial).get("foo"); 144 | * console.log(foo.bar); // "bar value" 145 | * ``` 146 | * 147 | * @param token the Token by which the class will be known. 148 | * @param cls the class to be provided, must match the InjectableClass type. 149 | */ 150 | providesClass = < 151 | Class extends InjectableClass, 152 | AdditionalDependencies extends ConstructorParameters, 153 | Tokens extends Class["dependencies"], 154 | Service extends ConstructorReturnType, 155 | Token extends TokenType, 156 | >( 157 | token: Token, 158 | cls: Class 159 | ) => this.provides(ClassInjectable(token, cls)); 160 | 161 | /** 162 | * In order to create a [Container], the InjectableFunctions maintained by the PartialContainer must be memoized 163 | * into Factories that can resolve their dependencies and return the correct Service. 164 | * 165 | * In particular, this requires access to a "parent" Container to avoid infinite looping in cases where Service A 166 | * depends on Service A – this is allowed (as long as the parent container provides Service A), but requires access 167 | * to the parent Container to provide the parent implementation of Service A. 168 | * 169 | * This also means that Services provided by a PartialContainer to a Container via this function will always be 170 | * scoped to the Container. In other words, if a PartialContainer containing Service A is provided to both 171 | * Container X and Container Y, when Service A is resolved by Container X the InjectableFunction used to create 172 | * Service A will be invoked – and when Service A is resolved by Container Y, the InjectableFunction will be invoked 173 | * again. 174 | * 175 | * @param parent A [Container] which provides all the required Dependencies of this PartialContainer. 176 | */ 177 | getFactories(parent: Container): PartialContainerFactories { 178 | let factories: PartialContainerFactories | undefined = undefined; 179 | return (factories = Object.fromEntries( 180 | entries(this.injectables).map(([token, fn]) => [ 181 | token, 182 | memoize(parent, () => 183 | fn( 184 | ...(fn.dependencies.map((t) => { 185 | return t === token 186 | ? parent.get(t as keyof Dependencies) 187 | : factories![t as keyof Services & Dependencies] 188 | ? factories![t]() 189 | : parent.get(t as keyof Dependencies); 190 | }) as any) 191 | ) 192 | ), 193 | ]) 194 | ) as PartialContainerFactories); 195 | } 196 | 197 | getTokens(): Array { 198 | return Object.keys(this.injectables) as Array; 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /src/__tests__/Container.spec.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line max-classes-per-file 2 | import { Container } from "../Container"; 3 | import { Injectable } from "../Injectable"; 4 | import { PartialContainer } from "../PartialContainer"; 5 | import type { InjectableFunction } from "../types"; 6 | 7 | function mockInjectable>(fn: Fn): Fn { 8 | const mockFn: any = jest.fn().mockImplementation(fn); 9 | mockFn.token = fn.token; 10 | mockFn.dependencies = fn.dependencies; 11 | return mockFn; 12 | } 13 | 14 | describe("Container", () => { 15 | let container: Container; 16 | 17 | beforeEach(() => { 18 | container = new Container({}); 19 | }); 20 | 21 | describe("when creating a new Container", () => { 22 | describe("by providing a Service", () => { 23 | let injectable: InjectableFunction; 24 | let containerWithService: Container<{ TestService: string }>; 25 | 26 | beforeEach(() => { 27 | injectable = mockInjectable(Injectable("TestService", () => "testService")); 28 | containerWithService = Container.provides(injectable); 29 | }); 30 | 31 | test("the Container provides the Service", () => { 32 | expect(containerWithService.get(injectable.token)).toEqual(injectable()); 33 | }); 34 | }); 35 | 36 | describe("from object", () => { 37 | test("the Container provides the Service", () => { 38 | let fromObject = Container.fromObject({ service: 1 }); 39 | expect(fromObject.get("service")).toBe(1); 40 | }); 41 | }); 42 | 43 | describe("by providing a PartialContainer", () => { 44 | let service1: InjectableFunction; 45 | let service2: InjectableFunction; 46 | let partialContainer: PartialContainer<{ Service1: string; Service2: number }>; 47 | 48 | beforeEach(() => { 49 | service1 = mockInjectable(Injectable("Service1", () => "service1")); 50 | service2 = mockInjectable(Injectable("Service2", () => 42)); 51 | partialContainer = new PartialContainer({}).provides(service1).provides(service2); 52 | }); 53 | 54 | test("the Container provides all Services from the PartialContainer", () => { 55 | const combinedContainer = Container.provides(partialContainer); 56 | expect(combinedContainer.get(service1.token)).toBe(service1()); 57 | expect(combinedContainer.get(service2.token)).toBe(service2()); 58 | }); 59 | }); 60 | 61 | describe("by providing another Container", () => { 62 | let service1: InjectableFunction; 63 | let service2: InjectableFunction; 64 | let container: Container<{ Service1: string; Service2: number }>; 65 | 66 | beforeEach(() => { 67 | service1 = mockInjectable(Injectable("Service1", () => "service1")); 68 | service2 = mockInjectable(Injectable("Service2", () => 42)); 69 | container = new Container({}).provides(service1).provides(service2); 70 | }); 71 | 72 | test("the new Container provides all Services from the other Container", () => { 73 | const combinedContainer = Container.provides(container); 74 | expect(combinedContainer.get(service1.token)).toBe(service1()); 75 | expect(combinedContainer.get(service2.token)).toBe(service2()); 76 | }); 77 | }); 78 | }); 79 | 80 | describe("when providing a Service", () => { 81 | let injectable: InjectableFunction; 82 | let containerWithService: Container<{ TestService: string }>; 83 | 84 | beforeEach(() => { 85 | injectable = mockInjectable(Injectable("TestService", () => "testService")); 86 | containerWithService = container.provides(injectable); 87 | }); 88 | 89 | test("a new Container is returned which provides the Service", () => { 90 | expect(containerWithService.get(injectable.token)).toEqual(injectable()); 91 | }); 92 | 93 | test("the InjectableFunction is only called once to create the Service", () => { 94 | containerWithService.get(injectable.token); 95 | containerWithService.get(injectable.token); 96 | expect(injectable).toBeCalledTimes(1); 97 | }); 98 | }); 99 | 100 | describe("when providing a Service with dependencies", () => { 101 | let dependency: InjectableFunction; 102 | let injectable: InjectableFunction<{ TestDependency: string }, readonly ["TestDependency"], "TestService", string>; 103 | let containerWithService: Container<{ TestDependency: string; TestService: string }>; 104 | 105 | beforeEach(() => { 106 | injectable = mockInjectable( 107 | Injectable("TestService", ["TestDependency"] as const, (dep: string) => `${dep} + service`) 108 | ); 109 | dependency = mockInjectable(Injectable("TestDependency", () => "dependency")); 110 | containerWithService = container.provides(dependency).provides(injectable); 111 | }); 112 | 113 | test("a new Container is returned which provides the Service", () => { 114 | expect(containerWithService.get(injectable.token)).toEqual(injectable("dependency")); 115 | }); 116 | 117 | test("the InjectableFunctions for the Service and its dependencies are each called only once", () => { 118 | containerWithService.get(injectable.token); 119 | containerWithService.get(injectable.token); 120 | expect(dependency).toBeCalledTimes(1); 121 | expect(injectable).toBeCalledTimes(1); 122 | }); 123 | }); 124 | 125 | describe("when providing a Service using the same Token as an existing Service", () => { 126 | let injectable: InjectableFunction; 127 | let containerWithService: Container<{ TestService: string }>; 128 | 129 | beforeEach(() => { 130 | injectable = Injectable("TestService", () => "new service"); 131 | containerWithService = container.provides(Injectable("TestService", () => "old service")).provides(injectable); 132 | }); 133 | 134 | test("the new Service overwrites the old Service", () => { 135 | expect(containerWithService.get(injectable.token)).toEqual(injectable()); 136 | }); 137 | 138 | test("the new Service may inject the old Service", () => { 139 | const newInjectable = Injectable("TestService", ["TestService"] as const, (ts: string) => `replaced ${ts}`); 140 | containerWithService = containerWithService.provides(newInjectable); 141 | expect(containerWithService.get(injectable.token)).toEqual(newInjectable(injectable())); 142 | }); 143 | }); 144 | 145 | describe("when providing a Service using providesClass", () => { 146 | const container = Container.providesValue("value", 1); 147 | 148 | test("test simple case", () => { 149 | class Item { 150 | static dependencies = ["value"] as const; 151 | constructor(public value: number) {} 152 | } 153 | const containerWithService = container.providesClass("service", Item); 154 | expect(containerWithService.get("service")).toEqual(new Item(1)); 155 | }); 156 | 157 | test("error if class constructor arity doesn't match dependencies", () => { 158 | class Item { 159 | static dependencies = ["value", "value2"] as const; 160 | constructor(public value: number) {} 161 | } 162 | // @ts-expect-error should be failing to compile as the constructor doesn't match dependencies 163 | expect(() => container.providesClass("service", Item).get("service")).toThrow(); 164 | // should not fail now as we provide the missing dependency 165 | container.providesValue("value2", 2).providesClass("service", Item).get("service"); 166 | }); 167 | 168 | test("error if class constructor argument type doesn't match provided by container", () => { 169 | class Item { 170 | static dependencies = ["value"] as const; 171 | constructor(public value: string) {} 172 | } 173 | // @ts-expect-error must fail to compile as the constructor argument type doesn't match dependencies 174 | container.providesClass("service", Item).get("service"); 175 | // should not fail now as we provide the correct type 176 | container.providesValue("value", "1").providesClass("service", Item).get("service"); 177 | }); 178 | 179 | test("error if class constructor argument type doesn't match provided by container", () => { 180 | class Item { 181 | static dependencies = ["value"] as const; 182 | constructor( 183 | public value: number, 184 | public value2: string 185 | ) {} 186 | } 187 | // @ts-expect-error must fail to compile as the constructor arity type doesn't match dependencies array length 188 | container.providesValue("value2", "2").providesClass("service", Item).get("service"); 189 | }); 190 | }); 191 | 192 | describe("when providing a PartialContainer", () => { 193 | let service1: InjectableFunction; 194 | let service2: InjectableFunction; 195 | let dependenciesContainer: Container<{ Service1: string }>; 196 | let partialContainer: PartialContainer<{ Service2: number }>; 197 | 198 | beforeEach(() => { 199 | service1 = mockInjectable(Injectable("Service1", () => "service1")); 200 | service2 = mockInjectable(Injectable("Service2", () => 42)); 201 | 202 | dependenciesContainer = container.provides(service1); 203 | partialContainer = new PartialContainer({}).provides(service2); 204 | }); 205 | 206 | test("a new Container is returned that provides services from the given PartialContainer", () => { 207 | const combinedContainer = dependenciesContainer.provides(partialContainer); 208 | expect(combinedContainer.get(service1.token)).toBe(service1()); 209 | expect(combinedContainer.get(service2.token)).toBe(service2()); 210 | }); 211 | 212 | test("a new Container is returned that provides services factories of which are memoized", () => { 213 | dependenciesContainer.get(service1.token); 214 | const combinedContainer = dependenciesContainer.provides(partialContainer); 215 | combinedContainer.get(service1.token); 216 | expect(service1).toBeCalledTimes(1); 217 | }); 218 | 219 | test("a new Container is returned that provides overriden service", () => { 220 | // the service below uses the same token, but has different signature 221 | const service1Override = mockInjectable(Injectable("Service1", () => 1024)); 222 | const containerWithService1And2 = partialContainer.provides(service1Override); 223 | const combinedContainer = dependenciesContainer.provides(containerWithService1And2); 224 | const service1Value = combinedContainer.get(service1Override.token); 225 | expect(service1Value).toBe(service1Override()); 226 | }); 227 | }); 228 | 229 | describe("when appending a Service to an existing array of Services", () => { 230 | test("appends value to the array", () => { 231 | const container = Container.providesValue("service", [] as number[]) 232 | .appendValue("service", 1) 233 | .appendValue("service", 2) 234 | .appendValue("service", 3); 235 | expect(container.get("service")).toEqual([1, 2, 3]); 236 | }); 237 | 238 | test("appends class to the array", () => { 239 | interface In { 240 | value2(): number; 241 | } 242 | class Item implements In { 243 | static dependencies = ["value"] as const; 244 | constructor(public value: number) {} 245 | 246 | value2(): number { 247 | return this.value * 2; 248 | } 249 | } 250 | 251 | const container = Container.providesValue("value", 1) 252 | .providesValue("service", [] as In[]) 253 | .appendClass("service", Item) 254 | .appendClass("service", Item) 255 | .appendClass("service", Item); 256 | expect(container.get("service")).toEqual([new Item(1), new Item(1), new Item(1)]); 257 | }); 258 | 259 | test("appends factory to the array", () => { 260 | const container = Container.providesValue("service", [] as number[]) 261 | .providesValue("value", 1) 262 | .append(Injectable("service", ["value"] as const, (value: number) => value)) 263 | .append(Injectable("service", () => 2)) 264 | .append(Injectable("service", () => 3)); 265 | expect(container.get("service")).toEqual([1, 2, 3]); 266 | }); 267 | 268 | test("errors when the token is not registered", () => { 269 | // @ts-expect-error 270 | new Container({}).appendValue("service", 1); 271 | }); 272 | 273 | test("errors when the token is not of array type", () => { 274 | // @ts-expect-error 275 | Container.providesValue("service1", 1).appendValue("service", 2); 276 | }); 277 | 278 | test("errors when new value is of different type", () => { 279 | // @ts-expect-error 280 | Container.providesValue("service", [] as number[]).appendValue("service", "1"); 281 | }); 282 | }); 283 | 284 | describe("when providing another Container", () => { 285 | let service1: InjectableFunction; 286 | let service2: InjectableFunction; 287 | let dependenciesContainer: Container<{ Service1: string }>; 288 | let anotherContainer: Container<{ Service2: number }>; 289 | 290 | beforeEach(() => { 291 | service1 = mockInjectable(Injectable("Service1", () => "service1")); 292 | service2 = mockInjectable(Injectable("Service2", () => 42)); 293 | 294 | dependenciesContainer = container.provides(service1); 295 | anotherContainer = new Container({}).provides(service2); 296 | }); 297 | 298 | test("a new Container is returned that provides services from the other Container", () => { 299 | const combinedContainer = dependenciesContainer.provides(anotherContainer); 300 | expect(combinedContainer.get(service1.token)).toBe(service1()); 301 | expect(combinedContainer.get(service2.token)).toBe(service2()); 302 | }); 303 | 304 | test("a new Container is returned that provides services factories of which are memoized", () => { 305 | dependenciesContainer.get(service1.token); 306 | const combinedContainer = dependenciesContainer.provides(anotherContainer); 307 | combinedContainer.get(service1.token); 308 | expect(service1).toBeCalledTimes(1); 309 | }); 310 | 311 | test("a new Container is returned that provides overriden service", () => { 312 | // the service below uses the same token, but has different signature 313 | const service1Override = mockInjectable(Injectable("Service1", () => 1024)); 314 | const containerWithService1And2 = anotherContainer.provides(service1Override); 315 | const combinedContainer = dependenciesContainer.provides(containerWithService1And2); 316 | const service1Value = combinedContainer.get(service1Override.token); 317 | expect(service1Value).toBe(service1Override()); 318 | }); 319 | }); 320 | 321 | describe("when retrieving a Service", () => { 322 | test("an Error is thrown if the Container does not contain the Service", () => { 323 | // We have to force an error here – without the `as any`, this fails to compile (which typically protects 324 | // from this happening at runtime). We still test this case, because there are some edge cases (e.g. around 325 | // the order in which constants are initialized vs. when a InjectableFunction is created) in which failure 326 | // can happen at runtime. 327 | const container: Container<{ TestService: string }> = new Container({} as any); 328 | expect(() => container.get("TestService")).toThrowError('Could not find Service for Token "TestService"'); 329 | }); 330 | }); 331 | 332 | describe("when getting the Container Token", () => { 333 | test("the Container returns itself", () => { 334 | expect(container.get("$container")).toBe(container); 335 | }); 336 | }); 337 | 338 | describe("overrides", () => { 339 | test("overriding value is supplied to the parent container function as a dependency", () => { 340 | let containerWithOverride = Container.providesValue("value", 1) 341 | .provides(Injectable("service", ["value"], (value: number) => value)) 342 | .providesValue("value", 2); 343 | expect(containerWithOverride.get("service")).toBe(2); 344 | }); 345 | 346 | test("overriding value is ignored when override happens after service was initialized", () => { 347 | let parentContainer = Container.providesValue("value", 1).provides( 348 | Injectable("service", ["value"], (value: number) => value) 349 | ); 350 | 351 | expect(parentContainer.get("service")).toBe(1); 352 | 353 | let childContainerWithOverride = parentContainer.providesValue("value", 2); 354 | expect(childContainerWithOverride.get("service")).toBe(1); 355 | }); 356 | 357 | test("overriding with a different type changes resulting container's type", () => { 358 | const parentContainer = Container.providesValue("value", 1); 359 | let childContainerWithOverride = parentContainer.providesValue("value", "two"); 360 | 361 | // @ts-expect-error should be failing to compile as the type of the container has changed 362 | let numberValue: number = childContainerWithOverride.get("value"); 363 | 364 | let value: string = childContainerWithOverride.get("value"); 365 | expect(value).toBe("two"); 366 | 367 | const partialContainer = new PartialContainer({}).provides(Injectable("value", () => "three")); 368 | childContainerWithOverride = parentContainer.provides(partialContainer); 369 | value = childContainerWithOverride.get("value"); 370 | expect(value).toBe("three"); 371 | 372 | let extraContainer = Container.fromObject({ value: "four" }); 373 | childContainerWithOverride = parentContainer.provides(extraContainer); 374 | value = childContainerWithOverride.get("value"); 375 | expect(value).toBe("four"); 376 | }); 377 | }); 378 | 379 | describe("when making a copy of the Container", () => { 380 | let injectable: InjectableFunction; 381 | let containerWithService: Container<{ TestService: Date }>; 382 | 383 | beforeEach(() => { 384 | injectable = mockInjectable(Injectable("TestService", () => new Date())); 385 | containerWithService = container.provides(injectable); 386 | }); 387 | 388 | test("the new Container resolves the same Token to the same Service instance", () => { 389 | const copy = containerWithService.copy(); 390 | expect(copy.get("TestService")).toBeInstanceOf(Date); 391 | expect(copy.get("TestService")).toBe(containerWithService.get("TestService")); 392 | 393 | // The InjectableFunction is called once (in the source Container), and the result is shared with the copied 394 | // Container. 395 | expect(injectable).toBeCalledTimes(1); 396 | }); 397 | 398 | test("the new Container re-creates Services which are scoped to the copy", () => { 399 | const copy = containerWithService.copy(["TestService"]); 400 | expect(copy.get("TestService")).toBeInstanceOf(Date); 401 | expect(copy.get("TestService")).not.toBe(containerWithService.get("TestService")); 402 | 403 | // The InjectableFunction was used to create a separate Service instance for each Container. 404 | expect(injectable).toBeCalledTimes(2); 405 | }); 406 | }); 407 | 408 | describe("when running a Service", () => { 409 | let injectable: InjectableFunction; 410 | 411 | beforeEach(() => { 412 | injectable = mockInjectable(Injectable("TestService", () => new Date())); 413 | }); 414 | 415 | test("the Service factory function is invoked.", () => { 416 | container.run(injectable); 417 | expect(injectable).toBeCalledTimes(1); 418 | }); 419 | }); 420 | 421 | describe("when accessing factories", () => { 422 | test("the factories are returned", () => { 423 | let c = container.providesValue("service", "value"); 424 | expect(c.factories.service()).toEqual("value"); 425 | }); 426 | }); 427 | 428 | describe("when running a PartialContainer", () => { 429 | let service1: InjectableFunction; 430 | let service2: InjectableFunction; 431 | let partialContainer: PartialContainer<{ Service1: string; Service2: number }>; 432 | 433 | beforeEach(() => { 434 | service1 = mockInjectable(Injectable("Service1", () => "service1")); 435 | service2 = mockInjectable(Injectable("Service2", () => 42)); 436 | partialContainer = new PartialContainer({}).provides(service1).provides(service2); 437 | }); 438 | 439 | test("all factory functions in the PartialContainer are invoked", () => { 440 | container.run(partialContainer); 441 | expect(service1).toBeCalledTimes(1); 442 | expect(service2).toBeCalledTimes(1); 443 | }); 444 | }); 445 | }); 446 | -------------------------------------------------------------------------------- /src/__tests__/Injectable.spec.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "../Injectable"; 2 | 3 | describe("Injectable", () => { 4 | describe("when given invalid arguments", () => { 5 | test("a TypeError is thrown", () => { 6 | expect(() => Injectable("TestService", [] as any)).toThrowError(TypeError); 7 | }); 8 | }); 9 | 10 | describe("when given a function with arity unequal to the number of dependencies", () => { 11 | test("a TypeError is thrown", () => { 12 | expect(() => Injectable("TestService", [] as const, (_: any) => {})).toThrowError(TypeError); 13 | }); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/__tests__/PartialContainer.spec.ts: -------------------------------------------------------------------------------- 1 | import { Container } from "../Container"; 2 | import { Injectable } from "../Injectable"; 3 | import { PartialContainer } from "../PartialContainer"; 4 | import type { InjectableFunction } from "../types"; 5 | 6 | function mockInjectable>(fn: Fn): Fn { 7 | const mockFn: any = jest.fn().mockImplementation(fn); 8 | mockFn.token = fn.token; 9 | mockFn.dependencies = fn.dependencies; 10 | return mockFn; 11 | } 12 | 13 | describe("PartialContainer", () => { 14 | let container: PartialContainer; 15 | 16 | beforeEach(() => { 17 | container = new PartialContainer({}); 18 | }); 19 | 20 | describe("when providing a Service", () => { 21 | const dependenciesContainer = new Container({}); 22 | let injectable: InjectableFunction; 23 | let containerWithService: PartialContainer<{ TestService: string }>; 24 | 25 | beforeEach(() => { 26 | injectable = mockInjectable(Injectable("TestService", () => "testService")); 27 | containerWithService = container.provides(injectable); 28 | }); 29 | 30 | test("a new PartialContainer is returned, which provides the Service factory function.", () => { 31 | const testServiceFactory = containerWithService.getFactories(dependenciesContainer).TestService; 32 | expect(testServiceFactory()).toEqual(injectable()); 33 | }); 34 | test("the Service factory function is memoized.", () => { 35 | const testServiceFactory = containerWithService.getFactories(dependenciesContainer).TestService; 36 | testServiceFactory(); 37 | testServiceFactory(); 38 | expect(injectable).toBeCalledTimes(1); 39 | }); 40 | }); 41 | 42 | describe("when providing a Service with dependencies", () => { 43 | let injectable: InjectableFunction<{ Dependency: string }, readonly ["Dependency"], "TestService", string>; 44 | let containerWithService: PartialContainer<{ TestService: string }, { Dependency: string }>; 45 | 46 | beforeEach(() => { 47 | injectable = mockInjectable( 48 | Injectable("TestService", ["Dependency"] as const, (dependency: string) => { 49 | return `${dependency} + testService`; 50 | }) 51 | ); 52 | containerWithService = container.provides(injectable); 53 | }); 54 | 55 | describe("and those dependencies are fulfilled by a Container", () => { 56 | const dependencyInjectable = Injectable("Dependency", () => "dependency"); 57 | let combinedContainer: Container<{ Dependency: string; TestService: string }>; 58 | 59 | beforeEach(() => { 60 | combinedContainer = Container.provides(dependencyInjectable).provides(containerWithService); 61 | }); 62 | 63 | test("the Service can be resolved.", () => { 64 | expect(combinedContainer.get("TestService")).toEqual(injectable(dependencyInjectable())); 65 | }); 66 | }); 67 | 68 | describe("and those dependencies are fulfilled by the PartialContainer", () => { 69 | const dependencyInjectable = Injectable("Dependency", () => "dependency"); 70 | let combinedContainer: Container<{ Dependency: string; TestService: string }>; 71 | 72 | beforeEach(() => { 73 | const combinedPartialContainer = containerWithService.provides(dependencyInjectable); 74 | combinedContainer = Container.provides(combinedPartialContainer); 75 | }); 76 | 77 | test("the Service can be resolved.", () => { 78 | expect(combinedContainer.get("TestService")).toEqual(injectable(dependencyInjectable())); 79 | }); 80 | }); 81 | 82 | describe("and those dependencies are fulfilled by a Container and the PartialContainer", () => { 83 | const dependencyInjectableFromPartial = Injectable("Dependency", () => "from PartialContainer"); 84 | const dependencyInjectableFromContainer = Injectable("Dependency", () => "from Container"); 85 | let combinedContainer: Container<{ Dependency: string; TestService: string }>; 86 | 87 | beforeEach(() => { 88 | const combinedPartialContainer = containerWithService.provides(dependencyInjectableFromPartial); 89 | combinedContainer = Container.provides(dependencyInjectableFromContainer).provides(combinedPartialContainer); 90 | }); 91 | 92 | test("the Service is resolved with the dependency from the PartialContainer", () => { 93 | expect(combinedContainer.get("TestService")).toEqual(injectable(dependencyInjectableFromPartial())); 94 | }); 95 | }); 96 | }); 97 | 98 | describe("when providing a Service using the same Token as an existing Service", () => { 99 | describe("provided by the PartialContainer", () => { 100 | describe("and the new Service does not depend on the old Service", () => { 101 | const injectable = Injectable("TestService", () => "new service"); 102 | let containerWithService: PartialContainer<{ TestService: string }>; 103 | 104 | beforeEach(() => { 105 | containerWithService = container 106 | .provides(Injectable("TestService", () => "old service")) 107 | .provides(injectable); 108 | }); 109 | 110 | test("the new Service overwrites the old Service", () => { 111 | expect(new Container({}).provides(containerWithService).get("TestService")).toEqual(injectable()); 112 | }); 113 | }); 114 | 115 | describe("and the new Service depends on the old Service", () => { 116 | let oldInjectableFromPartial: InjectableFunction<{}, [], "TestService", string>; 117 | const injectable = Injectable("TestService", ["TestService"] as const, (ts: string) => `replaced ${ts}`); 118 | let containerWithService: PartialContainer<{ TestService: string }, {}>; 119 | 120 | beforeEach(() => { 121 | oldInjectableFromPartial = mockInjectable( 122 | Injectable("TestService", () => { 123 | return "old service from partial"; 124 | }) 125 | ); 126 | containerWithService = container.provides(oldInjectableFromPartial).provides(injectable); 127 | }); 128 | 129 | test( 130 | "the old Service is never invoked, and the PartialContainer must be provided to a Container " + 131 | "that fulfills the dependency", 132 | () => { 133 | expect(() => { 134 | new Container({}).provides(containerWithService).get("TestService"); 135 | }).toThrow(/Could not find Service for Token "TestService"/); 136 | 137 | new Container({}) 138 | .provides(Injectable("TestService", () => "old service from container")) 139 | .provides(containerWithService) 140 | .get("TestService"); 141 | 142 | expect(oldInjectableFromPartial).not.toBeCalled(); 143 | } 144 | ); 145 | 146 | test("the new Service is injected with the old Service", () => { 147 | const oldInjectableFromContainer = Injectable("TestService", () => "old service from container"); 148 | expect( 149 | new Container({}).provides(oldInjectableFromContainer).provides(containerWithService).get("TestService") 150 | ).toEqual(injectable(oldInjectableFromContainer())); 151 | }); 152 | }); 153 | }); 154 | 155 | describe("provide service using provideValue", () => { 156 | const dependenciesContainer = Container.provides(Injectable("TestService", () => "old service")); 157 | 158 | test("and the new Service does not override", () => { 159 | const partialContainer = new PartialContainer({}).providesValue("NewTestService", "new service"); 160 | expect(dependenciesContainer.provides(partialContainer).get("NewTestService")).toEqual("new service"); 161 | }); 162 | 163 | test("and the new Service does override", () => { 164 | const partialContainer = new PartialContainer({}).providesValue("TestService", "new service"); 165 | expect(dependenciesContainer.provides(partialContainer).get("TestService")).toEqual("new service"); 166 | }); 167 | }); 168 | 169 | describe("provide service using provideClass", () => { 170 | const dependenciesContainer = Container.provides(Injectable("TestService", () => "old service")); 171 | 172 | class NewTestService { 173 | static dependencies = ["TestService"] as const; 174 | constructor(public testService: string) {} 175 | } 176 | 177 | describe("and the new Service does not override", () => { 178 | const partialContainer = new PartialContainer({}).providesClass("NewTestService", NewTestService); 179 | test("fails if parent missing dependency", () => { 180 | // @ts-expect-error should be a compile error because nothing provides "TestService" 181 | expect(() => Container.provides(partialContainer).get("NewTestService")).toThrow( 182 | /Could not find Service for Token "TestService"/ 183 | ); 184 | }); 185 | test("succeeds if parent has dependency", () => { 186 | expect(dependenciesContainer.provides(partialContainer).get("NewTestService")).toBeInstanceOf(NewTestService); 187 | }); 188 | }); 189 | 190 | test("and the new Service does override", () => { 191 | const partialContainer = new PartialContainer({}) 192 | .providesValue("TestService", "old service") 193 | .providesClass("TestService", NewTestService); 194 | let testService = dependenciesContainer.provides(partialContainer).get("TestService"); 195 | expect(testService).toBeInstanceOf(NewTestService); 196 | expect(testService.testService).toEqual("old service"); 197 | }); 198 | }); 199 | 200 | describe("provided by an existing Container", () => { 201 | const dependenciesContainer = Container.provides(Injectable("TestService", () => "old service")); 202 | let combinedContainer: Container<{ TestService: string }>; 203 | 204 | describe("and the new Service does not depend on the old Service", () => { 205 | const injectable = Injectable("TestService", () => "new service"); 206 | 207 | beforeEach(() => { 208 | const partialContainer = new PartialContainer({}).provides(injectable); 209 | combinedContainer = dependenciesContainer.provides(partialContainer); 210 | }); 211 | 212 | test("the new Service overwrites the old Service", () => { 213 | expect(combinedContainer.get("TestService")).toEqual(injectable()); 214 | }); 215 | }); 216 | 217 | describe("and the new Service depends on the old Service", () => { 218 | const injectable = Injectable("TestService", ["TestService"] as const, (ts: string) => `replaced ${ts}`); 219 | 220 | beforeEach(() => { 221 | const partialContainer = new PartialContainer({}).provides(injectable); 222 | combinedContainer = dependenciesContainer.provides(partialContainer); 223 | }); 224 | 225 | test("the new Service is injected with the old Service", () => { 226 | expect(combinedContainer.get("TestService")).toEqual(injectable("old service")); 227 | }); 228 | }); 229 | }); 230 | }); 231 | }); 232 | -------------------------------------------------------------------------------- /src/__tests__/types.spec.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "../Injectable"; 2 | import type { ServicesFromInjectables } from "../types"; 3 | import { Container } from "../Container"; 4 | 5 | describe("ServicesFromInjectables", () => { 6 | test("correctly maps injectables to service types and allow container type definition before construction.", () => { 7 | const injectable1 = Injectable("Service1", () => "service1"); 8 | const injectable2 = Injectable("Service2", () => 42); 9 | 10 | const injectables = [injectable1, injectable2] as const; 11 | 12 | // Use ServicesFromInjectables to derive the services' types 13 | type Services = ServicesFromInjectables; 14 | 15 | // Services type is equivalent to: 16 | // { 17 | // Service1: string; 18 | // Service2: number; 19 | // } 20 | 21 | // Declare a container variable with the derived Services type 22 | // This allows us to reference the container with accurate typing before it's constructed, 23 | // ensuring type safety and enabling its use in type annotations elsewhere 24 | let container: Container; 25 | 26 | // Assign the container with the actual instance 27 | container = Container.provides(injectable1).provides(injectable2); 28 | 29 | // Retrieve services with accurate typing 30 | const service1 = container.get("Service1"); // Type: string 31 | const service2 = container.get("Service2"); // Type: number 32 | 33 | // @ts-expect-error 34 | expect(() => container.get("NonExistentService")).toThrow(); 35 | 36 | // @ts-expect-error 37 | const invalidService1: number = container.get("Service1"); 38 | // @ts-expect-error 39 | const invalidService2: string = container.get("Service2"); 40 | 41 | // Use the services 42 | expect(service1).toBe("service1"); 43 | expect(service2).toBe(42); 44 | }); 45 | 46 | test("handles injectables with dependencies and allow pre-definition of container type", () => { 47 | const injectableDep = Injectable("DepService", () => 100); 48 | const injectableMain = Injectable("MainService", ["DepService"] as const, (dep: number) => dep + 1); 49 | 50 | const injectables = [injectableDep, injectableMain] as const; 51 | 52 | type Services = ServicesFromInjectables; 53 | 54 | let container: Container; 55 | 56 | container = Container.provides(injectableDep).provides(injectableMain); 57 | 58 | expect(container.get("DepService")).toBe(100); 59 | expect(container.get("MainService")).toBe(101); 60 | }); 61 | 62 | test("enforces type safety when assigning services.", () => { 63 | const injectable1 = Injectable("Service1", () => "service1"); 64 | const injectable2 = Injectable("Service2", () => 42); 65 | 66 | const injectables = [injectable1, injectable2] as const; 67 | 68 | type Services = ServicesFromInjectables; 69 | 70 | // Correct assignment 71 | const services: Services = { 72 | Service1: "service1", 73 | Service2: 42, 74 | }; 75 | 76 | // Attempting incorrect assignments should result in TypeScript errors 77 | 78 | const invalidServices1: Services = { 79 | Service1: "service1", 80 | // @ts-expect-error 81 | Service2: "not a number", // Error: Type 'string' is not assignable to type 'number' 82 | }; 83 | 84 | const invalidServices2: Services = { 85 | // @ts-expect-error 86 | Service1: 123, // Error: Type 'number' is not assignable to type 'string' 87 | Service2: 42, 88 | }; 89 | 90 | // @ts-expect-error 91 | const invalidServices3: Services = { 92 | Service1: "service1", 93 | // Missing 'Service2' property 94 | }; 95 | 96 | // avoid the "unused variable" TypeScript error 97 | expect(services ?? invalidServices1 ?? invalidServices2 ?? invalidServices3).toBeDefined(); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /src/entries.ts: -------------------------------------------------------------------------------- 1 | // `Object.entries` does not use `keyof` types, so it loses type specificity. We'll fix this with a wrapper. 2 | export const entries = , U>(o: T): Array<[keyof T, T[keyof T]]> => 3 | Object.entries(o) as unknown as Array<[keyof T, T[keyof T]]>; 4 | 5 | // `Object.fromEntries` similarly does not preserve key types. 6 | export const fromEntries = (entries: ReadonlyArray<[K, V]>): Record => 7 | Object.fromEntries(entries) as Record; 8 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { CONTAINER, Container } from "./Container"; 2 | export { Injectable, InjectableCompat, ConcatInjectable } from "./Injectable"; 3 | export { PartialContainer } from "./PartialContainer"; 4 | export { InjectableFunction, InjectableClass, ServicesFromInjectables } from "./types"; 5 | -------------------------------------------------------------------------------- /src/memoize.ts: -------------------------------------------------------------------------------- 1 | type AnyFunction = (...args: A) => B; 2 | 3 | export type Memoized = { 4 | (...args: Parameters): ReturnType; 5 | delegate: Fn; 6 | thisArg: any; 7 | }; 8 | 9 | export function isMemoized(fn: unknown): fn is Memoized { 10 | return typeof fn === "function" && typeof (fn as any).delegate === "function"; 11 | } 12 | 13 | export function memoize(thisArg: any, delegate: Fn): Memoized { 14 | let memo: any; 15 | const memoized = (...args: any[]) => { 16 | if (typeof memo !== "undefined") return memo; 17 | memo = delegate.apply(memoized.thisArg, args); 18 | return memo; 19 | }; 20 | memoized.delegate = delegate; 21 | memoized.thisArg = thisArg; 22 | return memoized; 23 | } 24 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { Container, ContainerToken } from "./Container"; 2 | 3 | type AsTuple = T extends readonly any[] ? T : never; 4 | 5 | type CorrespondingService> = Token extends ContainerToken 6 | ? Container 7 | : Token extends keyof Services 8 | ? Services[Token] 9 | : never; 10 | 11 | /** 12 | * Token type for associating services in a container, supporting strings, numbers, or symbols. 13 | */ 14 | export type TokenType = string | number | symbol; 15 | 16 | /** 17 | * Given a Services object, the valid Tokens are simply the keys of that object or the special Container Token. 18 | */ 19 | export type ValidTokens = ContainerToken | keyof Services; 20 | 21 | /** 22 | * Given Services, map from a list of Tokens to a list of Service types. 23 | */ 24 | export type CorrespondingServices[]> = { 25 | [K in keyof Tokens]: Tokens[K] extends ValidTokens ? CorrespondingService : never; 26 | }; 27 | 28 | /** 29 | * A valid `InjectableFunction` is one that can be successfully called, given some Services, to return a new Service. 30 | * That is, it must satisfy two conditions: 31 | * 32 | * 1. All the Tokens it specifies as dependencies are valid given the Services (i.e. they are either the Container 33 | * Token or keys of the Services type). 34 | * 2. The function argument types correspond to the Services specified by the dependency Tokens. 35 | * 36 | * A `InjectableFunction` also includes its own key Token and dependency Tokens as metadata, so it may be resolved by 37 | * Container later. 38 | */ 39 | export type InjectableFunction< 40 | Services, 41 | Tokens, 42 | Token extends TokenType, 43 | Service, 44 | > = Tokens extends readonly ValidTokens[] 45 | ? { 46 | (...args: AsTuple>): Service; 47 | token: Token; 48 | dependencies: Tokens; 49 | } 50 | : never; 51 | 52 | /** 53 | * Represents a class that can be used as an injectable service within a dependency injection {@link Container}. 54 | * The `InjectableClass` type ensures that the class's dependencies and constructor signature align with 55 | * the services available in the container, providing strong type safety. 56 | */ 57 | export type InjectableClass = Tokens extends readonly ValidTokens[] 58 | ? { 59 | readonly dependencies: Tokens; 60 | new (...args: AsTuple>): Service; 61 | } 62 | : never; 63 | 64 | export type AnyInjectable = InjectableFunction; 65 | 66 | /** 67 | * Maps an array of {@link InjectableFunction} to a service type object, where each key is the token of an 68 | * {@link Injectable}, and the corresponding value is the return type of that {@link Injectable}. 69 | * 70 | * This utility type is useful for deriving the service types provided by a collection of {@link InjectableFunction}s, 71 | * ensuring type safety and consistency throughout your application. 72 | * 73 | * You can use `ServicesFromInjectables` to construct a type that serves as a type parameter for a {@link Container}, 74 | * allowing the container's type to accurately reflect the services it provides, 75 | * even before the container is constructed. 76 | * 77 | * @typeParam Injectables - A tuple of {@link InjectableFunction}s. 78 | * 79 | * @example 80 | * // Define some Injectable functions 81 | * const injectable1 = Injectable("Service1", () => "service1"); 82 | * const injectable2 = Injectable("Service2", () => 42); 83 | * 84 | * // Collect them in a tuple 85 | * const injectables = [injectable1, injectable2] as const; 86 | * 87 | * // Use ServicesFromInjectables to derive the services' types 88 | * type Services = ServicesFromInjectables; 89 | * 90 | * // Services type is equivalent to: 91 | * // { 92 | * // Service1: string; 93 | * // Service2: number; 94 | * // } 95 | * 96 | * // Declare a container variable with the derived Services type 97 | * // This allows us to reference the container with accurate typing before it's constructed, 98 | * // ensuring type safety and enabling its use in type annotations elsewhere 99 | * let container: Container; 100 | * 101 | * // Assign the container with the actual instance 102 | * container = Container.provides(injectable1).provides(injectable2); 103 | */ 104 | export type ServicesFromInjectables = { 105 | [Name in Injectables[number]["token"]]: ReturnType>; 106 | }; 107 | 108 | /** 109 | * Add a Service with a Token to an existing set of Services. 110 | */ 111 | // Using a conditional type forces TS language services to evaluate the type -- so when showing e.g. type hints, we 112 | // will see the mapped type instead of the AddService type alias. This produces better hints. 113 | export type AddService = ParentServices extends any 114 | ? // A mapped type produces better, more concise type hints than an intersection type. 115 | { 116 | [K in keyof ParentServices | Token]: K extends keyof ParentServices 117 | ? K extends Token 118 | ? Service 119 | : ParentServices[K] 120 | : Service; 121 | } 122 | : never; 123 | 124 | /** 125 | * Same as AddService above, but is merging multiple services at once. Services types override those of the parent. 126 | */ 127 | // Using a conditional type forces TS language services to evaluate the type -- so when showing e.g. type hints, we 128 | // will see the mapped type instead of the AddService type alias. This produces better hints. 129 | export type AddServices = ParentServices extends any 130 | ? Services extends any 131 | ? { 132 | [K in keyof Services | keyof ParentServices]: K extends keyof Services 133 | ? Services[K] 134 | : K extends keyof ParentServices 135 | ? ParentServices[K] 136 | : never; 137 | } 138 | : never 139 | : never; 140 | 141 | /** 142 | * Create an object type from two tuples of the same length. The first tuple contains the object keys and the 143 | * second contains the value types corresponding to those keys. 144 | * 145 | * Ex: 146 | * ```ts 147 | * type FooBar = ServicesFromTokenizedParams<['foo', 'bar'], [string, number]> 148 | * const foobar: FooBar = {foo: 'foo', bar: 1} 149 | * const badfoobar: FooBar = {foo: 1, bar: 'bar'} // any extra, missing, or mis-typed properties raise an error. 150 | * ``` 151 | */ 152 | export type ServicesFromTokenizedParams = Tokens extends readonly [] 153 | ? Params extends readonly [] 154 | ? {} 155 | : never 156 | : Tokens extends readonly [infer Token, ...infer RemainingTokens] 157 | ? Params extends readonly [infer Param, ...infer RemainingParams] 158 | ? Tokens["length"] extends Params["length"] 159 | ? Token extends ContainerToken 160 | ? Param extends Container 161 | ? S & ServicesFromTokenizedParams 162 | : never 163 | : Token extends TokenType 164 | ? { [K in Token]: Param extends Container ? S : Param } & ServicesFromTokenizedParams< 165 | RemainingTokens, 166 | RemainingParams 167 | > 168 | : never 169 | : never 170 | : never 171 | : never; 172 | -------------------------------------------------------------------------------- /tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "downlevelIteration": true, 5 | "module": "commonjs", 6 | "noEmit": false, 7 | "outDir": "./dist/cjs", 8 | "target": "ES2022" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "noEmit": false, 6 | "outDir": "./dist/esm", 7 | "target": "ES2022" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.jest.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "esModuleInterop": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "declaration": false, 5 | "inlineSources": true, 6 | "lib": ["esnext"], 7 | "module": "esnext", 8 | "moduleResolution": "node", 9 | "noEmit": true, 10 | "noImplicitReturns": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "outDir": "dist", 14 | "preserveConstEnums": true, 15 | "removeComments": true, 16 | "rootDir": "src", 17 | "sourceMap": true, 18 | "strict": true, 19 | "stripInternal": true, 20 | "target": "esnext" 21 | }, 22 | "include": ["src/**/*.ts"], 23 | "exclude": ["src/**/*.spec.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /tsconfig.types.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.esm.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "declarationDir": "./dist/types", 6 | "declarationMap": true, 7 | "emitDeclarationOnly": true, 8 | "module": "es2015", 9 | "noEmit": false, 10 | "removeComments": false, 11 | "target": "ES2022" 12 | } 13 | } 14 | --------------------------------------------------------------------------------