Skip to content
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

Closed

Conversation

alecgibson
Copy link

@alecgibson alecgibson commented Jul 5, 2024

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:

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

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:

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:

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.

Related Issue

#788

How Has This Been Tested?

  • added tests to this test suite, which passes
  • based on type definitions we've been using in Production ourselves

Types of changes

  • Updated docs / Refactor code / Added a tests case (non-breaking change)
  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)

Checklist:

  • My code follows the code style of this project.
  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.
  • I have read the CONTRIBUTING document.
  • I have added tests to cover my changes.
  • All new and existing tests passed.
  • I have updated the changelog.

@alecgibson alecgibson mentioned this pull request Jul 5, 2024
@alecgibson alecgibson force-pushed the strongly-typed-injection branch 3 times, most recently from efff40c to 131c0fa Compare July 5, 2024 13:25
}

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>;
Copy link
Author

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
)

@alecgibson
Copy link
Author

@PodaruDragos any thoughts on this?

@PodaruDragos
Copy link
Contributor

this looks really good to me, @notaphplover what do you think ?

@notaphplover
Copy link
Member

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.

Copy link
Member

@notaphplover notaphplover left a 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!

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);
Copy link
Member

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

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>);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
export type ServiceIdentifier<T = unknown> = (PropertyServiceIdentifier | Newable<T> | Abstract<T>);
export type ServiceIdentifier<T = unknown> = (string | symbol | Newable<T> | Abstract<T>);

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;
Copy link
Member

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

Copy link
Author

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;
Copy link
Member

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.

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>>;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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.

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] :
Copy link
Member

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?

Copy link
Author

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?

Copy link
Member

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';
Copy link
Member

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)

export type PropertyServiceIdentifier = (string | symbol);
export type ServiceIdentifier<T = unknown> = (PropertyServiceIdentifier | Newable<T> | Abstract<T>);
export type BindingMap = Record<PropertyServiceIdentifier, any>;
Copy link
Contributor

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

Copy link
Author

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?

Copy link
Contributor

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

Copy link
Author

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

Copy link
Contributor

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
@notaphplover
Copy link
Member

notaphplover commented Oct 24, 2024

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 inversify shouldn't be the place to include this enhancement. Would you consider extracting a plugin? I wouldn't mind adding a reference to the plugin in the docs.

If you want we can have a discussion through discord or any other channel so we can have a videocall and
exchange our different points of view :)

@alecgibson
Copy link
Author

Even if it's a great idea, we do believe inversify shouldn't be the place to include this enhancement. Would you consider extracting a plugin?

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:

  • more complicated type definitions to maintain (but that's kind of the point of the change)
  • constrains other possible future use of a generic type signature on Container (if that matters?)

On the other hand, moving to a plugin comes with all the downsides of plugins:

  • increased maintenance and general overheads of running and maintaining a second repo/package
  • increased friction for consumers to use it (need to find it, and figure out how to install/use it, and it's yet another dependency)
  • less discoverable functionality
  • very one-way power dynamic with API changes: requires very pro-active engagement with "core" library maintainers to keep up with any potential breaks
  • can go stale from lack of maintenance if people aren't careful

At the very least I think getting the plugin housed under the inversify organisation would help to alleviate some of that pain, but it still feels like a lot of work for not much benefit (as I see it).

If you want we can have a discussion through discord or any other channel so we can have a videocall and
exchange our different points of view :)

Sounds sensible. Will email you separately.

@PodaruDragos
Copy link
Contributor

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 IdentifierMap. I would consider giving this another thought since for the normal inversify user this really does not change a thing. Or maybe I am missing something.

@alecgibson
Copy link
Author

As discussed offline, the main apprehensions here are that:

  • Adding a type mapping to this starts to declare a library "philosophy" that might tie the hands of the library later if exploring different philosophies, or trying to stay relatively design agnostic.
  • @notaphplover 's main concerns right now are simplifying the library (not adding complexity), and internal refactoring
  • While the maintenance burden of the types should hopefully be low, they're still non-critical, and a break in the type definitions shouldn't block the build of the core library

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 npm stats could also provide a pretty low-effort way of seeing what adoption of the feature is like in terms of downloads vs core library downloads.

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)

@notaphplover
Copy link
Member

Closing in favor of the plugin approach in the monorepo

@notaphplover
Copy link
Member

notaphplover commented Nov 23, 2024

As promised, a library has been published to provide this feature 🎉. I hope you enjoy it. Special thanks to @alecgibson who made this possible.

@alecgibson alecgibson deleted the strongly-typed-injection branch November 25, 2024 09:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
Archived in project
Development

Successfully merging this pull request may close these issues.

4 participants