Closed JoshuaKGoldberg closed 2 days ago
Hi @JoshuaKGoldberg I don't know if I'm getting you. Is InjectionTypes.Dependency
is a class @inject(InjectionTypes.Dependency)
should work with:
container.bind<typeof InjectionTypes.Dependency>(InjectionTypes.Dependency).toSelf();
But based on your second example:
type MyInjections = {
Dependency: typeof Dependency;
};
I'm wondering if InjectionTypes.Dependency
is a class? because typeof Dependency
is not a class.
If using a class maybe the problem is that when @inject(InjectionTypes.Dependency)
is invoked (when the class declaration is executed) maybe it is likely to be too early for InjectionTypes.Dependency
to be declared. The latest release has a fix for this called LazyServiceIdentifer
:
import { LazyServiceIdentifer, inject } from "inversify";
// ...
@inject(new LazyServiceIdentifer(() => InjectionTypes.Dependency))
You can also declare a helper:
const lazyEvaluatedInject = (id: any) => inject(new LazyServiceIdentifer(() =>id));
// ...
@lazyEvaluatedInject(InjectionTypes.Dependency)
The issue I'm trying to solve is that there's no guarantee at development time that we correctly hook up our injections. Giving a more complete example:
import { Container, inject, injectable } from "inversify";
@injectable()
class Dependency { }
@injectable()
class Sample {
@inject(Dependency)
dependency: number; // this is clearly wrong
}
const container = new Container();
container.bind(Dependency).toSelf();
container.bind(Sample).toSelf();
const sample = container.get(Sample);
I know this is an old issue, but was looking for a place to document the fact that we've got this working and I'm actually quite happy with our setup now.
It works pretty well. The main issues are:
public
(for the type system to see them)Hopefully this will help someone!
TypedContainer
import {interfaces} from 'inversify';
type StringKey<T> = keyof T & string;
type TypedContainerBase<T extends Record<string, any>> = {
bind: <K extends StringKey<T>, B extends T[K]>(serviceIdentifier: K) => interfaces.BindingToSyntax<B>;
rebind: <K extends StringKey<T>, B extends T[K]>(serviceIdentifier: K) => interfaces.BindingToSyntax<B>;
get: <K extends StringKey<T>, B extends T[K]>(serviceIdentifier: K) => B;
// Other overrides as needed
};
export type TypedContainer<T extends Record<string, any>> = TypedContainerBase<T> &
Omit<interfaces.Container, keyof TypedContainerBase<T>>;
$getDecorators()
import {inject, multiInject} from 'inversify';
type StringKey<T> = keyof T & string;
type TypedDecorator<T> = <O extends Record<K, T>, K extends keyof O>(
target: O,
targetKey?: K,
indexOrPropertyDescriptor?: any,
) => void;
type InjectDecorator<T> = <K extends StringKey<T>>(binding: K) => TypedDecorator<T[K]>;
export interface IDecorators<T> {
$inject: InjectDecorator<T>;
// Other decorators as needed
}
export function $getDecorators<T extends Record<string, any>>(): IDecorators<T> {
const $inject: InjectDecorator<T> = (binding) => {
return inject(binding) as ReturnType<InjectDecorator<T>>;
};
return {
$inject,
};
}
export type Bindings = {
Foo: IFoo;
Bar: IBar;
}
export const {$inject} = $getDecorators<Bindings>();
export const container: TypedContainer<Bindings> = new Container();
container.bind('Foo').to(Foo); // This is now strongly-typed
@injectable()
public class Test {
@$inject('Foo')
public readonly foo: IFoo; // strongly typed!
@$inject('Bar')
public readonly bar: IFoo; // uh-oh: compiler error, should be `IBar`
}
@alecgibson this is really cool, maybe there is a way we can add this to inversify
itself, maybe with a new decorator typedInject
or just do a breaking change and change the inject
itself. I don't really know how hard that would be but i would assume it's not that trivial. Anyways if anyone want to PR this I would be happy to get it merged.
@PodaruDragos I can try to have a look at getting this into inversify
itself. I think it needs to be a separate decorator, because my special @$inject()
decorator doesn't let you do private
property injection, which is a valid pattern that some people may be using @typedInject()
sounds like a sensible name.
I think I can probably make the Container
strongly-typed in a non-breaking way, but I haven't look properly yet. Will report back.
@PodaruDragos I've raised https://github.com/inversify/InversifyJS/pull/1575 as a first part of this change: just adding strong types to Container
(in itself quite a big change!). Once that's in, I can look into strong typing for injections as a follow-up.
I really like that approach. Want to get more opinions on this from members though. @notaphplover, @dcavanagh @jakehamtexas what do you guys think about this ?
The idea is so cool! Unfortunately, I'm afraid this cannot be done in Typescript, closing issue.
@notaphplover this can be done in TypeScript. Did you see my comment?
I already have the first part of this change raised as a non-breaking change ready in https://github.com/inversify/InversifyJS/pull/1575 but nobody's reviewed it.
Hey @alecgibson, I'm sorry, I did not read your comment.
I'll have a look at your approach, I need some days to carefully think about this enhancement. At first glance, I like the approach, it might be useful to some developers.
To give you my 5 cents, I think we can improve InjectDecorator<T>
. I don't see a reason to support only string
keys, symbols
could also be allowed. In addition, if, given a key K
, K
does not extend key of T
and K extends Newable<infer O>
, it would be safe to asume we are providing services extending O
.
Having said that, I don't like having this Bindings
type. By using this interace, developers needs to manually maintain it, at the end injecting a service would be error prone whatsoever since we still can make the same mistake in the Bindings type.
This is the main con I got here. I will leave the issue open for discussion :).
I don't see a reason to support only
string
keys,symbols
could also be allowed.
Yeah this is a fair comment. It's currently written this way just because we only use string
s and it simplified my definitions 😅
I don't like having this
Bindings
type. By using this interace, developers needs to manually maintain it, at the end injecting a service would be error prone whatsoever since we still can make the same mistake in the Bindings type.
@notaphplover I'm not sure I understand your point here? Yes it's manually maintained, but defining it is like defining any other interface in TypeScript: it will enforce a contract between the Container
and anything injecting a dependency.
That is, given:
interface Bindings {
Foo: {foo: string};
}
const container: TypedContainer<Bindings> = new Container();
You get this:
container.bind('Foo').toConstantValue({foo: 'foo'}) // ok
container.bind('Foo').toConstantValue({bar: 'bar'}) // compilation error
@injectable()
class UsesFoo {
@injectTyped('Foo')
foo: {foo: string}; // ok
@injectTyped('Foo')
bar: {bar: string}; // compilation error
}
So yes the Binding
map is "manually" maintained, but everything downstream then derives types and adheres to the contract it sets up, which is the whole point of interfaces in the general case anyway? In the same way that even outside of DI, if you set up a class the implements an interface, the interface is "manually" maintained, but offers an important type-safe level of abstraction in exchange for the overhead of some boilerplate code.
The above code is far less error-prone than using inversify
as-is, since inversify
will not uphold any contracts at all.
Hey @alecgibson ,
container.bind('Foo').toConstantValue({foo: 'foo'}) // ok container.bind('Foo').toConstantValue({bar: 'bar'}) // compilation error @injectable() class UsesFoo { @injectTyped('Foo') foo: {foo: string}; // ok @injectTyped('Foo') bar: {bar: string}; // compilation error }
Ohh I see. Brilliant! I was worried about the container, but it was only because I didn't fully understand your approach. Ok I'll have a word with @rsaz, we're trying to give love to the project and this idea looks amazing. I'll give you an answer in a day.
@notaphplover (and anyone else who cares) I've chucked all this code into Stackblitz if you want to have a play: https://stackblitz.com/edit/vitejs-vite-4xd9bx?file=src%2Fwarriors%2Fninja.ts
It deliberately has some compilation errors so you can see the type system at work. You can run npm run build
to see the failures in the console.
It could also be that this could exist as a plugin to inversify
(which is basically how we're using it now), since these type contracts don't actually do any implementation — they just sit on top of the existing classes.
The proposed draft PR looks great. Can't wait for it to be ready!
Hey all, at the end it's going to be a plugin in the monorepo. I'm working on some issues and refactors at this moment, but I haven't forgot about it ;)
As promised, a library has been published to provide this feature :tada:. I hope you enjoy it. Special thanks to @alecgibson who made this possible.
Consider this syntactically correct code:
I've made subtle mistakes around this enough to be emotionally invested in finding way to use the type system to find this class of mistake.
Is it at all possible to add an optional (
= any
) type onto the@injectable
and/or@inject
decorators? For example, something like:...or...
I've previously done something smaller and less difficult to reason about this in another package, which might be useful as a frame of reference.