-
Notifications
You must be signed in to change notification settings - Fork 726
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: strongly-typed Container
#1575
Conversation
efff40c
to
131c0fa
Compare
src/interfaces/interfaces.ts
Outdated
} | ||
|
||
export type Bind = <T = unknown>(serviceIdentifier: ServiceIdentifier<T>) => BindingToSyntax<T>; | ||
export type Bind<T extends BindingMap = any> = | ||
<B extends ContainerBinding<T, K>, K extends ContainerIdentifier<T> = any>(serviceIdentifier: K) => BindingToSyntax<B>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note, in the interest of keeping changes to a minimum, I haven't touched the BindingToSyntax
type, but we may want to consider tweaking it — either as part of this change, or a future change — because:
container.bind('foo').toDynamicValue(
({container}) => container.get('bar') // not strongly typed
)
@PodaruDragos any thoughts on this? |
this looks really good to me, @notaphplover what do you think ? |
I need time to review this :). The idea is brilliant, I'll give my feedback once I review it. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This PR is sort of challenging to review. I would love to discuss some concepts before continue reviewing.
Overal, the PR looks amazing, I think it's going to provide so much value to the container!
src/interfaces/interfaces.ts
Outdated
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); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This name is sort of misleading. A property can be decorated with any ServiceIdentifier
. Is this a BindingMapProperty
src/interfaces/interfaces.ts
Outdated
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>); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
export type ServiceIdentifier<T = unknown> = (PropertyServiceIdentifier | Newable<T> | Abstract<T>); | |
export type ServiceIdentifier<T = unknown> = (string | symbol | Newable<T> | Abstract<T>); |
src/interfaces/interfaces.ts
Outdated
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; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This feels redundant. keyof T & PropertyServiceIdentifier
should be equivalent to keyof T
since T is enforced to have an index of PropertyServiceIdentifier
. Am I missing something?
If so, lets remove this type in favor of keyof
usages
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree it feels redundant, but if you try to change:
-export type ContainerIdentifier<T extends BindingMap> = IfAny<T, ServiceIdentifier, BindingMapKey<T>>;
+export type ContainerIdentifier<T extends BindingMap> = IfAny<T, ServiceIdentifier, keyof T>;
Then you get a slew of compilation errors, along the lines of:
src/container/container.ts:181:33 - error TS2345: Argument of type 'string | number | symbol | Newable<unknown> | Abstract<unknown>' is not assignable to parameter of type 'ServiceIdentifier<unknown>'.
Type 'number' is not assignable to type 'ServiceIdentifier<unknown>'.
181 this._bindingDictionary.add(serviceIdentifier, binding as Binding<unknown>);
You can see from the error that number
has snuck in as a possible identifier, which ServiceIdentifier
doesn't allow.
I think this is from the possibility that the BindingMap
could be set to any
(if no map is provided, which is the backwards-compatible case). any
can technically have number
as a key, hence the error.
We need to keep the any
possibility to allow for containers that use non-string
or symbol
identifiers, (eg class identifiers) which is incompatible with this BindingMap
approach, since classes can't be property keys.
If you can find a way to get this to compile without this, then I'm all ears, but this definitely feels like the simplest solution to me.
@@ -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; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Really smart, but sort of tricky. Tbh I don't see a better way to represent this.
src/interfaces/interfaces.ts
Outdated
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>>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
export type ContainerIdentifier<T extends BindingMap> = IfAny<T, ServiceIdentifier, BindingMapKey<T>>; | |
export type MappedServiceIdentifier<T extends BindingMap> = IfAny<T, ServiceIdentifier, BindingMapKey<T>>; |
It feels more a mapped service identifier, isn't it? I think we don't need to involve the concept of container.
src/interfaces/interfaces.ts
Outdated
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] : |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I know I'm super picky with names, sry about it. I understand this is the service type of a binding, but not a binding. Am I right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No worries. Naming stuff is the hardest part of the job! It's especially tricky because I'm not super well-versed in this library, so have probably just been naming stuff wrong.
This type ContainerBinding<T, K>
is meant to represent the return value type of container<T>.get(key: K): ContainerBinding<T, K>
. So I guess it's whatever you want to call that?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Then I guess it's a ServiceType
(BindingMapServiceType
would be too long, wouldn't it be?)
@@ -0,0 +1,124 @@ | |||
import { interfaces } from '../../src/interfaces/interfaces'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
❤️
Thanks for adding this test module
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]: inversify#788 (comment)
src/interfaces/interfaces.ts
Outdated
|
||
export type PropertyServiceIdentifier = (string | symbol); | ||
export type ServiceIdentifier<T = unknown> = (PropertyServiceIdentifier | Newable<T> | Abstract<T>); | ||
export type BindingMap = Record<PropertyServiceIdentifier, any>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is there any way we can have this as Record<PropertyServiceIdentifier, unknown>
; ? avoiding the any
there i mean
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What's the benefit? I think any
is more semantic here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
no real benefit, keep consistency with most of the types since I tried really hard at some point to move away from most of any
's and have "proper" types
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I had a quick go, and it didn't immediately compile. I'm not personally sure it's worth investigating, because as I say the semantics aren't really "correct" (according to my limited understanding of unknown
vs any
😅 ), since the values assigned in this BindingMap
will be entirely consumer-generated, and they can legitimately set it to anything they want (ie it is semantically any
, right...?).
If you feel strongly I can try to investigate, but the types don't really like it as-is:
src/container/container.ts:287:41 - error TS2345: Argument of type 'this' is not assignable to parameter of type 'Container<BindingMap, any>'.
Type 'Container<T, P>' is not assignable to type 'Container<BindingMap, any>'.
The types returned by 'bind(...)' are incompatible between these types.
Type 'BindingToSyntax<ContainerBinding<T, K>>' is not assignable to type 'BindingToSyntax<B>'.
Type 'ContainerBinding<T, K>' is not assignable to type 'B'.
'ContainerBinding<T, K>' is assignable to the constraint of type 'B', but 'B' could be instantiated with a different subtype of constraint 'unknown'.
Type 'ContainerBinding<T, string> | ContainerBinding<T, symbol>' is not assignable to type 'B'.
'ContainerBinding<T, string> | ContainerBinding<T, symbol>' is assignable to the constraint of type 'B', but 'B' could be instantiated with a different subtype of constraint 'unknown'.
Type 'ContainerBinding<T, string>' is not assignable to type 'B'.
'ContainerBinding<T, string>' is assignable to the constraint of type 'B', but 'B' could be instantiated with a different subtype of constraint 'unknown'.
Type 'T[keyof T & string]' is not assignable to type 'B'.
'T[keyof T & string]' is assignable to the constraint of type 'B', but 'B' could be instantiated with a different subtype of constraint 'unknown'.
Type 'T[string]' is not assignable to type 'B'.
'T[string]' is assignable to the constraint of type 'B', but 'B' could be instantiated with a different subtype of constraint 'unknown'.
287 const request = createMockRequest(this, serviceIdentifier, key, value);
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't really think you can go around this tbh. If you can that would be amazing. depends what @notaphplover thinks also
- `PropertyServiceIdentifier` -> `BindingMapProperty` - `ContainerIdentifier` -> `MappedServiceIdentifier` - revert `ServiceIdentifier` type to previous definition
131c0fa
to
7aa00bb
Compare
Hey @alecgibson I've been analizying this with @rsaz for long time. We've been discussing pros and cons of this approach and which would be the logical enhancements once this strong typed container is ready. Even if it's a great idea, we do believe If you want we can have a discussion through discord or any other channel so we can have a videocall and |
A plugin is a legitimate architectural decision, although I am curious to hear your reasoning why? The way I see it, this adds a nice bit of backwards-compatible enhancement with basically no additional bloat. The main downsides I can think of are:
On the other hand, moving to a plugin comes with all the downsides of plugins:
At the very least I think getting the plugin housed under the
Sounds sensible. Will email you separately. |
I would also like to hear the cons here, as far as I can see this enhancement it really does not provide a minus for people who don't want to use this at all. The definitions for ServiceIdentifiers and the autocomplete stays the same so you will get the same experience as if there was no |
As discussed offline, the main apprehensions here are that:
I still personally would love to see this in the core library one day, but extracting a plugin can hopefully be a low-risk way of battle testing this. I guess weirdly In terms of plugin interface, we're leaning towards a pretty minimalist package inside the monorepo, which would just expose type definitions, and consumers can do something like: import {Container} from 'inversify';
import type {TypedContainer} from '@inversify/strongly-typed'; // (or whatever we call it)
import type {BindingMap} from './binding-map.js';
export const container: TypedContainer<BindingMap> = new Container(); Can do something similar for decorators: import {inject} from 'inversify';
import type {TypedInject} from '@inversify/strongly-typed';
import type {BindingMap} from './binding-map.js';
export const typedInject: TypedInject<BindingMap> = inject; (This is basically how we're currently doing it in our own code anyway and seems to work pretty well) |
Closing in favor of the plugin approach in the monorepo |
As promised, a library has been published to provide this feature 🎉. I hope you enjoy it. Special thanks to @alecgibson who made this possible. |
Description
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:
Furthermore, this proposal paves the way for type-safe injection, 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:If a
Container
is typed like this, we now get strong typing both when binding, and getting bindings: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.Related Issue
#788
How Has This Been Tested?
Types of changes
Checklist: