Skip to content

Commit

Permalink
feat(strongly-typed): initial commit
Browse files Browse the repository at this point in the history
Add an initial offering for `@inversifyjs/strongly-typed`, which will
let consumers optionally apply strong typing to their `Container`.
  • Loading branch information
alecgibson committed Nov 1, 2024
1 parent a82f20e commit e80b1d3
Show file tree
Hide file tree
Showing 16 changed files with 608 additions and 0 deletions.
19 changes: 19 additions & 0 deletions packages/container/libraries/strongly-typed/.gitignore
Original file line number Diff line number Diff line change
@@ -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/

Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"*.js": [
"prettier --write"
],
"*.ts": [
"prettier --write",
"eslint"
]
}
17 changes: 17 additions & 0 deletions packages/container/libraries/strongly-typed/.npmignore
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions packages/container/libraries/strongly-typed/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# @inversifyjs/strongly-typed
59 changes: 59 additions & 0 deletions packages/container/libraries/strongly-typed/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
[![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 can be used in a couple of ways, depending on your preference.

All usages wind up with you having a strongly-typed `TypedContainer`, which is a generic class that accepts a binding map as a type argument, which forms the contract that your container will adhere to:

```ts
import { TypedContainer } from '@inversifyjs/strongly-typed';

interface Foo { foo: string }
interface Bar { bar: string }

interface BindingMap {
foo: Foo;
bar: Bar;
}

const container = new TypedContainer<BindingMap>();

// 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
```

### Instantiation

The simplest way to use the library is to directly construct a `TypedContainer`:

```ts
import { TypedContainer } from '@inversifyjs/strongly-typed';

const container = new TypedContainer<BindingMap>();
```

This class is actually just a re-typed re-export of the vanilla `Container`, so shares all underlying functionality.

### Type assertion

If you'd prefer to keep this library out of your final dependency tree, you can just import the types and perform a type assertion:

```ts
import { Container } from 'inversify';
// The type import will be stripped during transpilation
import type { TypedContainer } from '@inversifyjs/strongly-typed';

const container = new Container() as TypedContainer<BindingMap>;
```
3 changes: 3 additions & 0 deletions packages/container/libraries/strongly-typed/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import myconfig from '@inversifyjs/foundation-eslint-config';

export default [...myconfig];
3 changes: 3 additions & 0 deletions packages/container/libraries/strongly-typed/jest.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { tsGlobalConfig } from '@inversifyjs/foundation-jest-config';

export default tsGlobalConfig;
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { jsGlobalConfig } from '@inversifyjs/foundation-jest-config';

export default jsGlobalConfig;
73 changes: 73 additions & 0 deletions packages/container/libraries/strongly-typed/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import config from '@inversifyjs/foundation-prettier-config';

export default config;
183 changes: 183 additions & 0 deletions packages/container/libraries/strongly-typed/src/container.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/* 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 './index';

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>('foo').to(Foo);
// @ts-expect-error :: can't bind Bar to Foo
container.bind<Foo>('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>('foo');
// @ts-expect-error :: can't assign Bar to Foo
foo = container.get<Bar>('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>('foo');
// @ts-expect-error :: can't assign Bar to Foo
foos = container.getAll<Bar>('bar');
});
});
});

describe('binding map', () => {
interface BindingMap {
foo: Foo;
bar: Bar;
asyncNumber: Promise<number>;
}

let container: TypedContainer<BindingMap>;

beforeEach(() => {
container = new Container() as TypedContainer<BindingMap>;
});

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);
container.bind('asyncNumber').toConstantValue(Promise.resolve(1));
});

it('gets a promise', async () => {
// @ts-expect-error :: can't call get() to get Promise
expect(() => container.get('asyncNumber')).toThrow(
'it has asynchronous dependencies',
);
const n: number = await container.getAsync('asyncNumber');
expect(n).toBe(1);
});

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') as 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');
});
});

describe('instantiation', () => {
it('constructs', () => {
container = new TypedContainer<BindingMap>();
container.bind('foo').to(Foo);
// @ts-expect-error :: can't bind Bar to Foo
container.bind('foo').to(Bar);
});
});
});
});
});
Loading

0 comments on commit e80b1d3

Please sign in to comment.