diff --git a/packages/container/libraries/strongly-typed/.gitignore b/packages/container/libraries/strongly-typed/.gitignore new file mode 100644 index 00000000..288feba2 --- /dev/null +++ b/packages/container/libraries/strongly-typed/.gitignore @@ -0,0 +1,19 @@ +# Typescript compiled files +/lib/** + +/tsconfig.tsbuildinfo +/tsconfig.cjs.tsbuildinfo +/tsconfig.esm.tsbuildinfo + +# Test coverage report +/coverage + +# Test mutation report +/reports + +# node modules +/node_modules/ + +# Turborepo files +.turbo/ + diff --git a/packages/container/libraries/strongly-typed/.lintstagedrc.json b/packages/container/libraries/strongly-typed/.lintstagedrc.json new file mode 100644 index 00000000..0061dfc2 --- /dev/null +++ b/packages/container/libraries/strongly-typed/.lintstagedrc.json @@ -0,0 +1,9 @@ +{ + "*.js": [ + "prettier --write" + ], + "*.ts": [ + "prettier --write", + "eslint" + ] +} diff --git a/packages/container/libraries/strongly-typed/.npmignore b/packages/container/libraries/strongly-typed/.npmignore new file mode 100644 index 00000000..2259a258 --- /dev/null +++ b/packages/container/libraries/strongly-typed/.npmignore @@ -0,0 +1,17 @@ +**/*.spec.js +**/*.spec.js.map +**/*.ts +!lib/**/*.d.ts +lib/**/*.spec.d.ts + +.lintstagedrc.json +eslint.config.mjs +jest.config.mjs +jest.config.stryker.mjs +jest.js.config.mjs +prettier.config.mjs +stryker.config.mjs +tsconfig.json +tsconfig.cjs.json +tsconfig.esm.json +tsconfig.tsbuildinfo diff --git a/packages/container/libraries/strongly-typed/CHANGELOG.md b/packages/container/libraries/strongly-typed/CHANGELOG.md new file mode 100644 index 00000000..8f2dcd74 --- /dev/null +++ b/packages/container/libraries/strongly-typed/CHANGELOG.md @@ -0,0 +1 @@ +# @inversifyjs/strongly-typed diff --git a/packages/container/libraries/strongly-typed/README.md b/packages/container/libraries/strongly-typed/README.md new file mode 100644 index 00000000..72e58456 --- /dev/null +++ b/packages/container/libraries/strongly-typed/README.md @@ -0,0 +1,38 @@ +[![Test coverage](https://codecov.io/gh/inversify/monorepo/branch/main/graph/badge.svg?flag=%40inversifyjs%2Fstrongly-typed)](https://codecov.io/gh/inversify/monorepo/branch/main/graph/badge.svg?flag=%40inversifyjs%2Fstrongly-typed) +[![npm version](https://img.shields.io/github/package-json/v/inversify/monorepo?filename=packages%2Fcontainer%2Flibraries%2Fstrongly-typed%2Fpackage.json&style=plastic)](https://www.npmjs.com/package/@inversifyjs/strongly-typed) + +# @inversifyjs/strongly-typed + +Type definitions for adding strong typing to the `Container` and `@inject()` decorator. + +## Usage + +This library contains **no functionality**, and only exposes type definitions to augment the core `inversify` implementation. + +These type definitions rely on creating a binding interface along the lines of: + +It can be used in conjunction with the core library simply by casting with a type assertion: + +```ts +import { Container } from 'inversify'; +import type { TypedContainer } from '@inversifyjs/strongly-typed'; + +interface Foo { foo: string } +interface Bar { bar: string } + +interface BindingMap { + foo: Foo; + bar: Bar; +} + +export const container = new Container() as TypedContainer; + +// Bindings are now strongly typed: + +container.bind('foo').toConstantValue({foo: 'abc'}); // ok +container.rebind('foo').toConstantValue({unknown: 'uh-oh'}) // compilation error + +let foo: Foo = container.get('foo') // ok +foo = container.get('bar') // compilation error +foo = container.get('unknown-identifier') // compilation error +``` diff --git a/packages/container/libraries/strongly-typed/eslint.config.mjs b/packages/container/libraries/strongly-typed/eslint.config.mjs new file mode 100644 index 00000000..42002283 --- /dev/null +++ b/packages/container/libraries/strongly-typed/eslint.config.mjs @@ -0,0 +1,3 @@ +import myconfig from '@inversifyjs/foundation-eslint-config'; + +export default [...myconfig]; diff --git a/packages/container/libraries/strongly-typed/jest.config.mjs b/packages/container/libraries/strongly-typed/jest.config.mjs new file mode 100644 index 00000000..7425b9fa --- /dev/null +++ b/packages/container/libraries/strongly-typed/jest.config.mjs @@ -0,0 +1,3 @@ +import { tsGlobalConfig } from '@inversifyjs/foundation-jest-config'; + +export default tsGlobalConfig; diff --git a/packages/container/libraries/strongly-typed/jest.js.config.mjs b/packages/container/libraries/strongly-typed/jest.js.config.mjs new file mode 100644 index 00000000..773dbb6f --- /dev/null +++ b/packages/container/libraries/strongly-typed/jest.js.config.mjs @@ -0,0 +1,3 @@ +import { jsGlobalConfig } from '@inversifyjs/foundation-jest-config'; + +export default jsGlobalConfig; diff --git a/packages/container/libraries/strongly-typed/package.json b/packages/container/libraries/strongly-typed/package.json new file mode 100644 index 00000000..f62b3868 --- /dev/null +++ b/packages/container/libraries/strongly-typed/package.json @@ -0,0 +1,73 @@ +{ + "author": "Alec Gibson", + "bugs": { + "url": "https://github.com/inversify/monorepo/issues" + }, + "description": "InversifyJs strong type definitions", + "devDependencies": { + "@eslint/js": "9.13.0", + "@jest/globals": "29.7.0", + "@types/node": "20.17.4", + "@typescript-eslint/eslint-plugin": "8.12.2", + "@typescript-eslint/parser": "8.12.2", + "eslint": "9.13.0", + "jest": "29.7.0", + "prettier": "3.3.3", + "ts-jest": "29.2.5", + "typescript": "5.6.3" + }, + "devEngines": { + "node": "^20.18.0", + "pnpm": "^9.12.1" + }, + "homepage": "https://inversify.io", + "keywords": [ + "dependency injection", + "dependency inversion", + "di", + "inversion of control container", + "ioc", + "javascript", + "node", + "typescript" + ], + "license": "MIT", + "main": "lib/cjs/index.js", + "module": "lib/esm/index.js", + "exports": { + ".": { + "import": "./lib/esm/index.js", + "require": "./lib/cjs/index.js" + } + }, + "name": "@inversifyjs/strongly-typed", + "os": [ + "darwin", + "linux" + ], + "peerDependencies": { + "inversify": ">=6.0.3" + }, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/inversify/monorepo.git" + }, + "scripts": { + "build": "pnpm run build:cjs && pnpm run build:esm", + "build:cjs": "tsc --build tsconfig.cjs.json && pnpm exec foundation-ts-package-cjs ./lib/cjs", + "build:esm": "tsc --build tsconfig.esm.json && pnpm exec foundation-ts-package-esm ./lib/esm", + "build:clean": "rimraf lib", + "format": "prettier --write ./src/**/*.ts", + "lint": "eslint ./src", + "test": "jest --config=jest.config.mjs --runInBand", + "test:js": "jest --config=jest.js.config.mjs --runInBand", + "test:js:coverage": "pnpm run test:unit:js --coverage", + "test:uncommitted": "pnpm run test --changedSince=HEAD", + "test:unit:js": "pnpm run test:js --selectProjects Unit" + }, + "sideEffects": false, + "version": "1.0.0" +} diff --git a/packages/container/libraries/strongly-typed/prettier.config.mjs b/packages/container/libraries/strongly-typed/prettier.config.mjs new file mode 100644 index 00000000..70361db5 --- /dev/null +++ b/packages/container/libraries/strongly-typed/prettier.config.mjs @@ -0,0 +1,3 @@ +import config from '@inversifyjs/foundation-prettier-config'; + +export default config; diff --git a/packages/container/libraries/strongly-typed/src/container.spec.ts b/packages/container/libraries/strongly-typed/src/container.spec.ts new file mode 100644 index 00000000..58e9e8e3 --- /dev/null +++ b/packages/container/libraries/strongly-typed/src/container.spec.ts @@ -0,0 +1,158 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +/* eslint-disable jest/expect-expect */ +import { beforeEach, describe, expect, it } from '@jest/globals'; + +import 'reflect-metadata'; + +import { Container, injectable } from 'inversify'; + +import { TypedContainer } from './container'; + +describe('interfaces', () => { + @injectable() + class Foo { + public foo: string = ''; + } + + @injectable() + class Bar { + public bar: string = ''; + } + + describe('Container', () => { + let foo: Foo; + let foos: Foo[]; + + beforeEach(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + foo; + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + foos; + }); + + describe('no binding map', () => { + let container: TypedContainer; + + beforeEach(() => { + container = new Container() as TypedContainer; + }); + + describe('bind()', () => { + it('binds without a type argument', () => { + container.bind('foo').to(Foo); + container.bind(Foo).to(Foo); + }); + + it('checks bindings with an explicit type argument', () => { + container.bind('foo').to(Foo); + // @ts-expect-error :: can't bind Bar to Foo + container.bind('foo').to(Bar); + }); + + it('binds a class as a service identifier', () => { + container.bind(Foo).to(Foo); + // @ts-expect-error :: can't bind Bar to Foo + container.bind(Foo).to(Bar); + }); + }); + + describe('get()', () => { + beforeEach(() => { + container.bind('foo').to(Foo); + container.bind('bar').to(Bar); + container.bind(Foo).to(Foo); + container.bind(Bar).to(Bar); + }); + + it('gets an anonymous binding', () => { + foo = container.get('foo'); + }); + + it('enforces type arguments', () => { + foo = container.get('foo'); + // @ts-expect-error :: can't assign Bar to Foo + foo = container.get('bar'); + }); + + it('gets a class identifier', () => { + foo = container.get(Foo); + // @ts-expect-error :: can't assign Bar to Foo + foo = container.get(Bar); + }); + + it('gets all', () => { + foos = container.getAll('foo'); + // @ts-expect-error :: can't assign Bar to Foo + foos = container.getAll('bar'); + }); + }); + }); + + describe('binding map', () => { + let container: TypedContainer<{ foo: Foo; bar: Bar }>; + + beforeEach(() => { + container = new Container() as TypedContainer<{ foo: Foo; bar: Bar }>; + }); + + describe('bind()', () => { + it('enforces strict bindings', () => { + container.bind('foo').to(Foo); + // @ts-expect-error :: can't bind Bar to Foo + container.bind('foo').to(Bar); + // @ts-expect-error :: unknown service identifier + container.bind('unknown').to(Foo); + }); + }); + + describe('get()', () => { + beforeEach(() => { + container.bind('foo').to(Foo); + container.bind('bar').to(Bar); + }); + + it('enforces strict bindings', () => { + foo = container.get('foo'); + // @ts-expect-error :: can't assign Bar to Foo + foo = container.get('bar'); + // @ts-expect-error :: unknown service identifier + expect(() => container.get('unknown')).toThrow( + 'No matching bindings', + ); + }); + + it('gets all', () => { + foos = container.getAll('foo'); + // @ts-expect-error :: can't assign Bar to Foo + foos = container.getAll('bar'); + }); + }); + + describe('ancestry', () => { + beforeEach(() => { + container.bind('foo').to(Foo); + container.bind('bar').to(Bar); + }); + + it('tracks the types of ancestors', () => { + // eslint-disable-next-line @typescript-eslint/typedef + const child = container.createChild<{ lorem: string }>(); + child.bind('lorem').toConstantValue('lorem'); + foo = child.parent!.get('foo'); + // @ts-expect-error :: can't assign Bar to Foo + foo = child.parent!.get('bar'); + + // eslint-disable-next-line @typescript-eslint/typedef + const grandchild = child.createChild<{ ipsum: string }>(); + const lorem: string = grandchild.parent!.get('lorem'); + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + lorem; + + foo = grandchild.parent!.parent!.get('foo'); + // @ts-expect-error :: can't assign Bar to Foo + foo = grandchild.parent!.parent!.get('bar'); + }); + }); + }); + }); +}); diff --git a/packages/container/libraries/strongly-typed/src/container.ts b/packages/container/libraries/strongly-typed/src/container.ts new file mode 100644 index 00000000..6f84e0d4 --- /dev/null +++ b/packages/container/libraries/strongly-typed/src/container.ts @@ -0,0 +1,197 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unnecessary-type-parameters */ +import type { interfaces } from 'inversify'; + +type IfAny = 0 extends 1 & T ? TYes : TNo; + +type BindingMapProperty = string | symbol; +type BindingMap = Record; +type BindingMapKey = keyof T & BindingMapProperty; +type MappedServiceIdentifier = IfAny< + T, + interfaces.ServiceIdentifier, + BindingMapKey +>; +type ContainerBinding< + T extends BindingMap, + TKey extends MappedServiceIdentifier = any, +> = TKey extends keyof T + ? T[TKey] + : TKey extends interfaces.Newable + ? C + : TKey extends interfaces.Abstract + ? D + : never; + +type First = T extends [infer TFirst, ...any[]] + ? TFirst + : never; + +type AllButFirst = T extends [any, ...infer TRest] + ? TRest + : never; + +interface ContainerOverrides< + T extends BindingMap = any, + TAncestors extends BindingMap[] = any[], +> { + parent: ContainerOverrides, AllButFirst> | null; + bind: Bind; + get: < + TBound extends ContainerBinding, + TKey extends MappedServiceIdentifier = any, + >( + serviceIdentifier: TKey, + ) => TBound; + getNamed: < + TBound extends ContainerBinding, + TKey extends MappedServiceIdentifier = any, + >( + serviceIdentifier: TKey, + named: PropertyKey, + ) => TBound; + getTagged: < + TBound extends ContainerBinding, + TKey extends MappedServiceIdentifier = any, + >( + serviceIdentifier: TKey, + key: PropertyKey, + value: unknown, + ) => TBound; + getAll: < + TBound extends ContainerBinding, + TKey extends MappedServiceIdentifier = any, + >( + serviceIdentifier: TKey, + ) => TBound[]; + getAllTagged: < + TBound extends ContainerBinding, + TKey extends MappedServiceIdentifier = any, + >( + serviceIdentifier: TKey, + key: PropertyKey, + value: unknown, + ) => TBound[]; + getAllNamed: < + TBound extends ContainerBinding, + TKey extends MappedServiceIdentifier = any, + >( + serviceIdentifier: TKey, + named: PropertyKey, + ) => TBound[]; + getAsync: < + TBound extends ContainerBinding, + TKey extends MappedServiceIdentifier = any, + >( + serviceIdentifier: TKey, + ) => Promise; + getNamedAsync: < + TBound extends ContainerBinding, + TKey extends MappedServiceIdentifier = any, + >( + serviceIdentifier: TKey, + named: PropertyKey, + ) => Promise; + getTaggedAsync: < + TBound extends ContainerBinding, + TKey extends MappedServiceIdentifier = any, + >( + serviceIdentifier: TKey, + key: PropertyKey, + value: unknown, + ) => Promise; + getAllAsync: < + TBound extends ContainerBinding, + TKey extends MappedServiceIdentifier = any, + >( + serviceIdentifier: TKey, + ) => Promise; + getAllTaggedAsync: < + TBound extends ContainerBinding, + TKey extends MappedServiceIdentifier = any, + >( + serviceIdentifier: TKey, + key: PropertyKey, + value: unknown, + ) => Promise; + getAllNamedAsync: < + TBound extends ContainerBinding, + TKey extends MappedServiceIdentifier = any, + >( + serviceIdentifier: TKey, + named: PropertyKey, + ) => Promise; + isBound: IsBound; + isBoundNamed: ( + serviceIdentifier: MappedServiceIdentifier, + named: PropertyKey, + ) => boolean; + isBoundTagged: ( + serviceIdentifier: MappedServiceIdentifier, + key: PropertyKey, + value: unknown, + ) => boolean; + isCurrentBound: IsBound; + rebind: Rebind; + rebindAsync: RebindAsync; + unbind: Unbind; + unbindAsync: UnbindAsync; + onActivation< + TBound extends ContainerBinding, + TKey extends MappedServiceIdentifier = any, + >( + serviceIdentifier: TKey, + onActivation: interfaces.BindingActivation, + ): void; + onDeactivation< + TBound extends ContainerBinding, + TKey extends MappedServiceIdentifier = any, + >( + serviceIdentifier: TKey, + onDeactivation: interfaces.BindingDeactivation, + ): void; + resolve(constructorFunction: interfaces.Newable): TBound; + createChild( + containerOptions?: interfaces.ContainerOptions, + ): TypedContainer; +} + +type Bind = < + TBound extends ContainerBinding, + TKey extends MappedServiceIdentifier = any, +>( + serviceIdentifier: TKey, +) => interfaces.BindingToSyntax; + +type Rebind = Bind; + +type RebindAsync = < + TBound extends ContainerBinding, + TKey extends MappedServiceIdentifier = any, +>( + serviceIdentifier: TKey, +) => Promise>; + +type Unbind = < + TKey extends MappedServiceIdentifier, +>( + serviceIdentifier: TKey, +) => void; + +type UnbindAsync = < + TKey extends MappedServiceIdentifier, +>( + serviceIdentifier: TKey, +) => Promise; + +type IsBound = < + TKey extends MappedServiceIdentifier, +>( + serviceIdentifier: TKey, +) => boolean; + +export type TypedContainer< + T extends BindingMap = any, + TAncestors extends BindingMap[] = any[], +> = ContainerOverrides & + Omit; diff --git a/packages/container/libraries/strongly-typed/src/index.ts b/packages/container/libraries/strongly-typed/src/index.ts new file mode 100644 index 00000000..5d4bc523 --- /dev/null +++ b/packages/container/libraries/strongly-typed/src/index.ts @@ -0,0 +1 @@ +export type { TypedContainer } from './container'; diff --git a/packages/container/libraries/strongly-typed/tsconfig.cjs.json b/packages/container/libraries/strongly-typed/tsconfig.cjs.json new file mode 100644 index 00000000..72630fb8 --- /dev/null +++ b/packages/container/libraries/strongly-typed/tsconfig.cjs.json @@ -0,0 +1,10 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "@inversifyjs/foundation-typescript-config/tsconfig.base.cjs.json", + "compilerOptions": { + "outDir": "./lib/cjs", + "rootDir": "./src", + "tsBuildInfoFile": "tsconfig.cjs.tsbuildinfo" + }, + "include": ["src"] +} diff --git a/packages/container/libraries/strongly-typed/tsconfig.esm.json b/packages/container/libraries/strongly-typed/tsconfig.esm.json new file mode 100644 index 00000000..e597177d --- /dev/null +++ b/packages/container/libraries/strongly-typed/tsconfig.esm.json @@ -0,0 +1,10 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "@inversifyjs/foundation-typescript-config/tsconfig.base.esm.json", + "compilerOptions": { + "outDir": "./lib/esm", + "rootDir": "./src", + "tsBuildInfoFile": "tsconfig.esm.tsbuildinfo" + }, + "include": ["src"] +} diff --git a/packages/container/libraries/strongly-typed/tsconfig.json b/packages/container/libraries/strongly-typed/tsconfig.json new file mode 100644 index 00000000..04a10c90 --- /dev/null +++ b/packages/container/libraries/strongly-typed/tsconfig.json @@ -0,0 +1,4 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "./tsconfig.esm.json" +}