Skip to content

Commit

Permalink
feat(strongly-typed): inject decorator type
Browse files Browse the repository at this point in the history
This change builds on the work of the [`TypedContainer`][1] by adding
type definitions for a strongly-typed `inject` decorator.

[1]: inversify#59
  • Loading branch information
alecgibson committed Nov 19, 2024
1 parent 207a5a5 commit e9c4823
Show file tree
Hide file tree
Showing 5 changed files with 167 additions and 1 deletion.
74 changes: 74 additions & 0 deletions packages/container/libraries/strongly-typed/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,44 @@ const container = new Container() as TypedContainer<BindingMap>;
```


## Injection

The library also exposes a `TypedInject` type that will leverage the `BindingMap` you've used to strongly type your `Container`.

You'll need to re-export the `inject` decorator with a type assertion:

```ts
import { inject } from 'inversify';
import type { TypedInject } from '@inversifyjs/strongly-typed';

export const $inject = inject as TypedInject<BindingMap>;
```

You can now use this to strongly type injected constructor parameters, or **public** properties:

```ts
@injectable()
class B {
public constructor(
@$inject('foo') // ok
foo: Foo,

@$inject('foo') // compilation error
bar: Bar,
) {}
}

@injectable()
class A {
@$inject('foo') // ok
public foo: Foo;

@$inject('foo') // compilation error
public bar: Bar;
}
```


## Advanced usage

### `Promise` bindings
Expand Down Expand Up @@ -108,3 +146,39 @@ const parent = new TypedContainer<{foo: Foo}>();
const child = parent.createChild<{foo: Bar}>();
const resolved: Bar = child.get('foo');
```


## Known issues / limitations

### Strongly-typing `private` properties

Note that because the decorator types can't "see" `private` properties, we can't strongly type them:

```ts
@injectable()
class A {
@$inject('foo')
private foo: Foo; // fails :'(
}
```

Work around this by either:

1. Making the property `public` (and perhaps prefixing it with an underscore to remind consumers it should be private)
2. Using the weakly-typed `@inject()` directly from `'inversify'`


### Confusing compiler errors for constructor injection

There's no custom compiler errors for TypeScript, so we can't signal particularly usefully that a constructor parameter is wrong, apart from the fact that the compilation fails.

The error will look something like:

```
Unable to resolve signature of parameter decorator when called as an expression.
Argument of type '2' is not assignable to parameter of type 'undefined'. (ts1239)
```

This actually means something like this:

> The injected constructor parameter at index `2` is of the wrong type.
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Container, type interfaces } from 'inversify';
type IfAny<T, TYes, TNo> = 0 extends 1 & T ? TYes : TNo;

type BindingMapProperty = string | symbol;
type BindingMap = Record<BindingMapProperty, any>;
export type BindingMap = Record<BindingMapProperty, any>;
type MappedServiceIdentifier<T extends BindingMap> = IfAny<
T,
interfaces.ServiceIdentifier,
Expand Down
1 change: 1 addition & 0 deletions packages/container/libraries/strongly-typed/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { TypedContainer } from './container';
export type { TypedInject } from './inject';
67 changes: 67 additions & 0 deletions packages/container/libraries/strongly-typed/src/inject.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/* eslint-disable @typescript-eslint/no-unused-expressions */
/* eslint-disable jest/expect-expect */
import { describe, it } from '@jest/globals';

import { inject, injectable } from 'inversify';

import type { TypedInject } from './inject';

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

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

interface BindingMap {
foo: Foo;
bar: Bar;
asyncNumber: Promise<number>;
}

const $inject: TypedInject<BindingMap> = inject as TypedInject<BindingMap>;

it('strongly types injected properties', () => {
class Test {
@$inject('foo')
public foo!: Foo;

@$inject('bar')
public readonly bar!: Bar;

// @ts-expect-error :: expects type Bar
@$inject('foo')
public badFoo!: Bar;

// @ts-expect-error :: unknown binding
@$inject('unknown')
public unknown!: unknown;
}
Test;
});

it('strongly types injected constructor parameters', () => {
class Test {
constructor(
@$inject('foo')
_foo: Foo,

@$inject('bar')
private readonly _bar: Bar,

// @ts-expect-error :: expects type Bar
@$inject('foo')
_badFoo: Bar,

// @ts-expect-error :: unknown binding
@$inject('unknown')
_unknown: unknown,
) {}
}
Test;
});
});
24 changes: 24 additions & 0 deletions packages/container/libraries/strongly-typed/src/inject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { BindingMap } from './container';

type TypedDecorator<T> = <TTarget, TKey, TIndex>(
target: TTarget extends new (...args: any[]) => any
? TTarget
: TKey extends keyof TTarget
? Record<TKey, T>
: never,
key: TKey extends keyof TTarget ? TKey : PropertyKey | undefined,
indexOrPropertyDescriptor?: TTarget extends new (...args: infer P) => any
? TIndex extends keyof P
? P[TIndex] extends T
? TIndex
: never
: never
: unknown,
) => void;

export type TypedInject<TBindingMap extends BindingMap> = <
TKey extends keyof TBindingMap,
>(
identifier: TKey,
) => TypedDecorator<TBindingMap[TKey]>;

0 comments on commit e9c4823

Please sign in to comment.