Skip to content

Commit

Permalink
feat: strongly-typed Container
Browse files Browse the repository at this point in the history
This is a non-breaking change, affecting only TypeScript types, and
doesn't change the implementation in any way.

Motivation
==========

`inversify` already has some basic support for types when binding, and
retrieving bindings.

However, the type support requires manual intervention from developers,
and can be error-prone.

For example, the following code will happily compile, even though the
types here are inconsistent:

```ts
container.bind<Bar>('bar').to(Bar);
const foo = container.get<Foo>('bar')
```

Furthermore, this paves the way for [type-safe injection][1], which will
be added once this change is in.

Improved type safety
====================

This change adds an optional type parameter to the `Container`, which
takes an identifier map as an argument. For example:

```ts
type IdentifierMap = {
  foo: Foo;
  bar: Bar;
};

const container = new Container<IdentifierMap>;
```

If a `Container` is typed like this, we now get strong typing both when
binding, and getting bindings:

```ts
const container = new Container<IdentifierMap>;

container.bind('foo').to(Foo); // ok
container.bind('foo').to(Bar); // error

const foo: Foo = container.get('foo') // ok
const bar: Bar = container.get('foo') // error
```

This also has the added benefit of no longer needing to pass around
service identifier constants: the strings (or symbols) are all strongly
typed, and will fail compilation if an incorrect one is used.

Non-breaking
============

This change aims to make no breaks to the existing types, so any
`Container` without an argument should continue to work as it did
before.

[1]: #788 (comment)
  • Loading branch information
alecgibson committed Jul 5, 2024
1 parent 7619dd4 commit 255acc1
Show file tree
Hide file tree
Showing 8 changed files with 384 additions and 159 deletions.
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- Optional strong typing for `Container`

### Changed

### Fixed
property injection tagged as @optional no longer overrides default values with `undefined`.
property injection tagged as @optional no longer overrides default values with `undefined`.

## [6.0.2]

Expand Down
299 changes: 179 additions & 120 deletions src/container/container.ts

Large diffs are not rendered by default.

101 changes: 68 additions & 33 deletions src/interfaces/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,16 @@ namespace interfaces {
prototype: T;
}

export type ServiceIdentifier<T = unknown> = (string | symbol | Newable<T> | Abstract<T>);
export type IfAny<T, Y, N> = 0 extends (1 & T) ? Y : N;

export type PropertyServiceIdentifier = (string | symbol);
export type ServiceIdentifier<T = unknown> = (PropertyServiceIdentifier | Newable<T> | Abstract<T>);
export type BindingMap = Record<PropertyServiceIdentifier, any>;
export type BindingMapKey<T extends BindingMap> = keyof T & PropertyServiceIdentifier;
export type ContainerIdentifier<T extends BindingMap> = IfAny<T, ServiceIdentifier, BindingMapKey<T>>;
export type ContainerBinding<T extends BindingMap, K extends ContainerIdentifier<T> = any> = K extends keyof T ? T[K] :
K extends Newable<infer C> ? C :
K extends Abstract<infer D> ? D : never;

export interface Clonable<T> {
clone(): T;
Expand Down Expand Up @@ -188,36 +197,53 @@ namespace interfaces {
skipBaseClassChecks?: boolean;
}

export interface Container {
export interface Container<T extends BindingMap = any, P extends BindingMap = any> {
id: number;
parent: Container | null;
parent: Container<P> | null;
options: ContainerOptions;
bind<T>(serviceIdentifier: ServiceIdentifier<T>): BindingToSyntax<T>;
rebind<T>(serviceIdentifier: interfaces.ServiceIdentifier<T>): interfaces.BindingToSyntax<T>;
rebindAsync<T>(serviceIdentifier: interfaces.ServiceIdentifier<T>): Promise<interfaces.BindingToSyntax<T>>
unbind(serviceIdentifier: ServiceIdentifier): void;
unbindAsync(serviceIdentifier: interfaces.ServiceIdentifier): Promise<void>;
bind: Bind<T>;
rebind: Rebind<T>;
rebindAsync: RebindAsync<T>;
unbind: Unbind<T>;
unbindAsync: UnbindAsync<T>;
unbindAll(): void;
unbindAllAsync(): Promise<void>;
isBound(serviceIdentifier: ServiceIdentifier): boolean;
isCurrentBound<T>(serviceIdentifier: ServiceIdentifier<T>): boolean;
isBoundNamed(serviceIdentifier: ServiceIdentifier, named: string | number | symbol): boolean;
isBoundTagged(serviceIdentifier: ServiceIdentifier, key: string | number | symbol, value: unknown): boolean;
get<T>(serviceIdentifier: ServiceIdentifier<T>): T;
getNamed<T>(serviceIdentifier: ServiceIdentifier<T>, named: string | number | symbol): T;
getTagged<T>(serviceIdentifier: ServiceIdentifier<T>, key: string | number | symbol, value: unknown): T;
getAll<T>(serviceIdentifier: ServiceIdentifier<T>): T[];
getAllTagged<T>(serviceIdentifier: ServiceIdentifier<T>, key: string | number | symbol, value: unknown): T[];
getAllNamed<T>(serviceIdentifier: ServiceIdentifier<T>, named: string | number | symbol): T[];
getAsync<T>(serviceIdentifier: ServiceIdentifier<T>): Promise<T>;
getNamedAsync<T>(serviceIdentifier: ServiceIdentifier<T>, named: string | number | symbol): Promise<T>;
getTaggedAsync<T>(serviceIdentifier: ServiceIdentifier<T>, key: string | number | symbol, value: unknown): Promise<T>;
getAllAsync<T>(serviceIdentifier: ServiceIdentifier<T>): Promise<T[]>;
getAllTaggedAsync<T>(serviceIdentifier: ServiceIdentifier<T>, key: string | number | symbol, value: unknown): Promise<T[]>;
getAllNamedAsync<T>(serviceIdentifier: ServiceIdentifier<T>, named: string | number | symbol): Promise<T[]>;
onActivation<T>(serviceIdentifier: ServiceIdentifier<T>, onActivation: BindingActivation<T>): void;
onDeactivation<T>(serviceIdentifier: ServiceIdentifier<T>, onDeactivation: BindingDeactivation<T>): void;
resolve<T>(constructorFunction: interfaces.Newable<T>): T;
isBound: IsBound<T>;
isCurrentBound: IsBound<T>;
isBoundNamed: <K extends ContainerIdentifier<T>>(serviceIdentifier: K, named: PropertyKey) => boolean;
isBoundTagged: <K extends interfaces.ContainerIdentifier<T>>(
serviceIdentifier: K,
key: PropertyKey,
value: unknown,
) => boolean;
get: Get<T>;
getNamed: <B extends interfaces.ContainerBinding<T, K>, K extends interfaces.ContainerIdentifier<T> = any>(
serviceIdentifier: K,
named: PropertyKey,
) => B;
getTagged: <B extends interfaces.ContainerBinding<T, K>, K extends interfaces.ContainerIdentifier<T> = any>(
serviceIdentifier: K,
key: PropertyKey,
value: unknown,
) => B;
getAll: All<Get<T>>;
getAllTagged: All<Container<T, P>['getTagged']>;
getAllNamed: All<Container<T, P>['getNamed']>;
getAsync: Async<Get<T>>;
getNamedAsync: Async<Container<T, P>['getNamed']>;
getTaggedAsync: Async<Container<T, P>['getTagged']>;
getAllAsync: Async<All<Get<T>>>;
getAllTaggedAsync: Async<All<Container<T, P>['getTagged']>>;
getAllNamedAsync: Async<All<Container<T, P>['getNamed']>>;
onActivation<B extends ContainerBinding<T, K>, K extends ContainerIdentifier<T> = any>(
serviceIdentifier: K,
onActivation: BindingActivation<B>,
): void;
onDeactivation<B extends ContainerBinding<T, K>, K extends ContainerIdentifier<T> = any>(
serviceIdentifier: K,
onDeactivation: BindingDeactivation<B>,
): void;
resolve<B>(constructorFunction: Newable<B>): B;
load(...modules: ContainerModule[]): void;
loadAsync(...modules: AsyncContainerModule[]): Promise<void>;
unload(...modules: ContainerModuleBase[]): void;
Expand All @@ -226,18 +252,27 @@ namespace interfaces {
applyMiddleware(...middleware: Middleware[]): void;
snapshot(): void;
restore(): void;
createChild(): Container;
createChild<C extends BindingMap = any>(containerOptions?: ContainerOptions): Container<C, T>;
}

export type Bind = <T = unknown>(serviceIdentifier: ServiceIdentifier<T>) => BindingToSyntax<T>;
type Async<T extends (...args: any) => any> = (...args: Parameters<T>) => Promise<ReturnType<T>>;
type All<T extends (...args: any) => any> = (...args: Parameters<T>) => ReturnType<T>[];

export type Bind<T extends BindingMap = any> =
<B extends ContainerBinding<T, K>, K extends ContainerIdentifier<T> = any>(serviceIdentifier: K) => BindingToSyntax<B>;

export type Rebind<T extends BindingMap = any> = Bind<T>;

export type RebindAsync<T extends BindingMap = any> = Async<Bind<T>>;

export type Rebind = <T = unknown>(serviceIdentifier: ServiceIdentifier<T>) => BindingToSyntax<T>;
export type Unbind<T extends BindingMap = any> = <K extends ContainerIdentifier<T>>(serviceIdentifier: K) => void;

export type Unbind = <T = unknown>(serviceIdentifier: ServiceIdentifier<T>) => void;
export type UnbindAsync<T extends BindingMap = any> = Async<Unbind<T>>;

export type UnbindAsync = <T = unknown>(serviceIdentifier: ServiceIdentifier<T>) => Promise<void>;
export type IsBound<T extends BindingMap = any> = <K extends ContainerIdentifier<T>>(serviceIdentifier: K) => boolean;

export type IsBound = <T = unknown>(serviceIdentifier: ServiceIdentifier<T>) => boolean;
type Get<T extends BindingMap = any> =
<B extends ContainerBinding<T, K>, K extends ContainerIdentifier<T> = any>(serviceIdentifier: K) => B;

export interface ContainerModuleBase {
id: number;
Expand Down
8 changes: 4 additions & 4 deletions src/planning/planner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,9 +217,9 @@ function getBindings<T>(
return bindings;
}

function plan(
function plan<T extends interfaces.Binding>(
metadataReader: interfaces.MetadataReader,
container: interfaces.Container,
container: interfaces.Container<T>,
isMultiInject: boolean,
targetType: interfaces.TargetType,
serviceIdentifier: interfaces.ServiceIdentifier,
Expand All @@ -245,8 +245,8 @@ function plan(

}

function createMockRequest(
container: interfaces.Container,
function createMockRequest<T extends interfaces.BindingMap>(
container: interfaces.Container<T>,
serviceIdentifier: interfaces.ServiceIdentifier,
key: string | number | symbol,
value: unknown
Expand Down
2 changes: 1 addition & 1 deletion src/syntax/binding_to_syntax.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ class BindingToSyntax<T> implements interfaces.BindingToSyntax<T> {
public toAutoNamedFactory<T2>(serviceIdentifier: interfaces.ServiceIdentifier<T2>): BindingWhenOnSyntax<T> {
this._binding.type = BindingTypeEnum.Factory;
this._binding.factory = (context) => {
return (named: unknown) => context.container.getNamed<T2>(serviceIdentifier, named as string);
return (named: unknown) => context.container.getNamed(serviceIdentifier, named as string);
};
return new BindingWhenOnSyntax<T>(this._binding);
}
Expand Down
108 changes: 108 additions & 0 deletions test/interfaces/interfaces.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { interfaces } from '../../src/interfaces/interfaces';
import {Container, injectable} from '../../src/inversify';
import {expect} from 'chai';

describe('interfaces', () => {
@injectable()
class Foo {
foo: string = '';
}

@injectable()
class Bar {
bar: string = '';
}

describe('Container', () => {
describe('no binding map', () => {
let container: interfaces.Container;
let foo: Foo;

beforeEach(() => {
container = new Container();
// tslint:disable-next-line: no-unused-expression
foo;
});

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

describe('binding map', () => {
let container: interfaces.Container<{foo: Foo; bar: Bar}>;
let foo: Foo;

beforeEach(() => {
container = new Container();
// tslint:disable-next-line: no-unused-expression
foo;
});

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')).to.throw('No matching bindings');
});
});
});
});
});
21 changes: 21 additions & 0 deletions wiki/container_typing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Strong Container typing

The `Container` can take an optional type argument defining a mapping of service identifiers to types. If defined, this
will add strong type-checking when declaring bindings, and when retrieving them.

For example:

```ts
type IdentifierMap = {
foo: Foo;
bar: Bar;
};

const container = new Container<IdentifierMap>;

container.bind('foo').to(Foo); // ok
container.bind('foo').to(Bar); // error

const foo: Foo = container.get('foo') // ok
const bar: Bar = container.get('foo') // error
```
1 change: 1 addition & 0 deletions wiki/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Welcome to the InversifyJS wiki!
- [Container API](./container_api.md)
- [Declaring container modules](./container_modules.md)
- [Container snapshots](./container_snapshots.md)
- [Strong Container typing](./container_typing.md)
- [Controlling the scope of the dependencies](./scope.md)
- [Declaring optional dependencies](./optional_dependencies.md)
- [Injecting a constant or dynamic value](./value_injection.md)
Expand Down

0 comments on commit 255acc1

Please sign in to comment.