microsoft / tsyringe

Lightweight dependency injection container for JavaScript/TypeScript
MIT License
5.18k stars 172 forks source link

Add @injectOptional() to handle optional constructor args #182

Open willieseabrook opened 3 years ago

willieseabrook commented 3 years ago

Is your feature request related to a problem? Please describe.

I'm about 2 days in to using tsyringe so I might be totally lost, but I can't see how to support optional constructor parameters out of the box.

  constructor(
    @inject('MyRequiredService') runtime: MyRequiredService,
    @inject('MyOptionalService') config?: MyOptionalService,
    ) {

If you look at the constructor above, config? is optional, and this is something the logic of my class expects.

However, if I do not register 'MyOptionalService', tsyringe will throw an error that it could not find a registration for 'MyOptionalService'

The logic of my code is that sometimes 'MyOptionalService' would be registered, sometimes it would not be registered.

Alternate solutions

My workaround is to hack with @injectWithTransform.

This is verbose.

And will (I think) only work with the global container, where as I am using child containers.

class Optional {
  getOptionalValue(token: string): unknown {
    try {
      return container.resolve(token)
    }
    catch (e) {
      return undefined
    }
  }
}

class OptionalTransform {
  public transform(optional: Optional, token: string): unknown {
    return optional.getOptionalValue(token)
  }
}

Then:

  constructor(
    @inject('MyRequiredService') runtime: MyRequiredService,
    @injectWithTransform(Optional, OptionalTransform, 'MyOptionalService') config?: MyOptionalService,
    ) {

Proposed Solution

Add @injectOptional() so that the following would work:

  constructor(
    @inject('MyRequiredService') runtime: MyRequiredService,
    @injectOptional('MyOptionalService') config?: MyOptionalService,
    ) {

@injectOptional would not throw an error if the 'MyOptionalService' is not registered, instead it would simply inject undefined.

Additional context

Inversify supports this feature. Just because that supports it doesn't mean tsyringe should, but it is an example implementation of how other people have approached this feature.

https://github.com/inversify/InversifyJS/blob/master/wiki/optional_dependencies.md

MeltingMosaic commented 3 years ago

Yeah, this has come up a time or two. Both Inversify and Angular implement it as well. I have been reticent to follow suit, because one thing I have always liked about DI is that there is a guarantee that either you get all of your parameters or you fail to resolve. Still, if people are performing workarounds like you have above, it seems sensible to allow users to opt into an optional resolution.

I'd at least look at a PR for this.

JinuPC commented 2 years ago

I have extended @willieseabrook solution by introducing a custom decorator

export function injectOptional(token:string) {
    return injectWithTransform(Optional, OptionalTransform, token);
}

Now we can use this decorator inside the constructors

constructor(
    @inject('MyRequiredService') runtime: MyRequiredService,
    @injectOptional('MyOptionalService') config?: MyOptionalService,
    ) {
eduardvercaemer commented 8 months ago

I came up with this solution to overcome the coupling with the root container abusing factory providers a little bit:

Full example:


container.register("TransientContainer", { useFactory: (c) => c });

@injectable()
class Optional {
  constructor(
    @inject("TransientContainer")
    private readonly container: DependencyContainer,
  ) {}

  getOptionalValue(token: InjectionToken): unknown {
    try {
      return this.container.resolve(token);
    } catch (e) {
      return null;
    }
  }
}

class OptionalTransform {
  public transform(optional: Optional, token: InjectionToken): unknown {
    return optional.getOptionalValue(token);
  }
}

function injectOptional(token: InjectionToken) {
  return injectWithTransform(Optional, OptionalTransform, token);
}

const childA = container.createChildContainer();
const childB = container.createChildContainer();
childA.register("Bar", { useValue: "my bar" });
const barA = childA.resolve(Foo).bar; // my bar!
const barB = childB.resolve(Foo).bar; // null

I am a little wary of the implications of calling resolve during another resolve chain but so far seems to work ok.

rwilliams3088 commented 3 months ago

+1 Would really like this to be an official feature. But will take inspiration from the great work-arounds posted above in the meantime :)