Advanced Injection-JS Features

4. Advanced Injection-JS Features

You’ve mastered the fundamentals and hierarchies. Now, let’s explore some of the more advanced features of Injection-JS that allow for highly flexible and powerful dependency management in complex applications.

Multi-Providers (multi: true)

Sometimes, you don’t want to provide a single instance of a service, but rather multiple instances or values associated with a single injection token. This is where multi-providers come in handy. When multi: true is used in a provider, instead of replacing previous definitions for that token, it adds to a collection. When the token is resolved, you get an array of all registered values.

Common Use Cases:

  • Registering multiple plugins for a specific extension point.
  • Providing multiple validation rules for a form field.
  • Aggregating event handlers or interceptors.
// src/app.ts (or a new file, e.g., multi-providers.ts)
import "reflect-metadata";
import { ReflectiveInjector, InjectionToken, Injectable, Inject } from 'injection-js';

// Define an InjectionToken for our "plugins"
export const PLUGIN_TOKEN = new InjectionToken<string>('PLUGIN_TOKEN');

// A service that will consume multiple plugins
@Injectable()
export class PluginHostService {
  constructor(@Inject(PLUGIN_TOKEN) private plugins: string[]) {
    console.log('PluginHostService initialized with plugins:', plugins);
  }

  runPlugins(): void {
    if (this.plugins && this.plugins.length > 0) {
      this.plugins.forEach(plugin => console.log(`Running plugin: ${plugin}`));
    } else {
      console.log("No plugins registered.");
    }
  }
}

// Define multiple providers for PLUGIN_TOKEN with multi: true
const providers = [
  { provide: PLUGIN_TOKEN, useValue: 'AuthPlugin', multi: true },
  { provide: PLUGIN_TOKEN, useValue: 'LoggingPlugin', multi: true },
  { provide: PLUGIN_TOKEN, useValue: 'CachingPlugin', multi: true },
  PluginHostService // PluginHostService will receive an array of plugin names
];

const injector = ReflectiveInjector.resolveAndCreate(providers);
const pluginHost = injector.get(PluginHostService);
pluginHost.runPlugins();

// You can also get the array directly
const allPlugins = injector.get(PLUGIN_TOKEN);
console.log("Directly retrieved plugins:", allPlugins);

Output:

PluginHostService initialized with plugins: [ 'AuthPlugin', 'LoggingPlugin', 'CachingPlugin' ]
Running plugin: AuthPlugin
Running plugin: LoggingPlugin
Running plugin: CachingPlugin
Directly retrieved plugins: [ 'AuthPlugin', 'LoggingPlugin', 'CachingPlugin' ]

Notice how PluginHostService received an array ['AuthPlugin', 'LoggingPlugin', 'CachingPlugin'] because multi: true was used for each provider associated with PLUGIN_TOKEN.

Optional Dependencies (@Optional())

By default, if a service requests a dependency that the injector cannot resolve, it will throw an error. However, sometimes a dependency is not strictly required, and your service can function perfectly well without it. In such cases, you can mark the dependency as optional using the @Optional() decorator.

If an optional dependency cannot be resolved, the injector will provide null (or undefined depending on the TypeScript version and strictness) instead of throwing an error.

// src/app.ts (or optional-dependency.ts)
import "reflect-metadata";
import { ReflectiveInjector, Injectable, Optional, Inject } from 'injection-js';

// A simple service that might be optional
@Injectable()
export class DebugService {
  log(message: string): void {
    console.log(`[DEBUG] ${message}`);
  }
}

// A service that may or may not use DebugService
@Injectable()
export class DataProcessorService {
  constructor(@Optional() @Inject(DebugService) private debugService: DebugService | null) {
    if (this.debugService) {
      this.debugService.log('DataProcessorService initialized with DebugService.');
    } else {
      console.log('DataProcessorService initialized without DebugService.');
    }
  }

  process(data: string): string {
    this.debugService?.log(`Processing data: ${data}`);
    return `Processed: ${data.toUpperCase()}`;
  }
}

// Scenario 1: DebugService is provided
const providersWithDebug = [
  DebugService,
  DataProcessorService
];
const injectorWithDebug = ReflectiveInjector.resolveAndCreate(providersWithDebug);
const processorWithDebug = injectorWithDebug.get(DataProcessorService);
console.log(processorWithDebug.process("hello"));

console.log('\n--- Separator ---\n');

// Scenario 2: DebugService is NOT provided
const providersWithoutDebug = [
  // No DebugService here
  DataProcessorService
];
const injectorWithoutDebug = ReflectiveInjector.resolveAndCreate(providersWithoutDebug);
const processorWithoutDebug = injectorWithoutDebug.get(DataProcessorService);
console.log(processorWithoutDebug.process("world"));

Output:

[DEBUG] DataProcessorService initialized with DebugService.
[DEBUG] Processing data: hello
Processed: HELLO

--- Separator ---

DataProcessorService initialized without DebugService.
Processed: WORLD

As you can see, DataProcessorService adapts gracefully whether DebugService is available or not.

Host, Self, SkipSelf Decorators (Contextual Resolution)

These decorators provide fine-grained control over how injection-js searches for a dependency within an injector hierarchy. They are particularly useful in component-based architectures where you might want to specify where in the hierarchy a dependency should be resolved.

While their primary use is in Angular’s component tree, they apply equally to injection-js when you manually build complex injector hierarchies.

  • @Self(): Limits the search for a dependency to the current injector only. If the current injector doesn’t provide it, an error is thrown (unless @Optional() is also used). It does not look at the parent injector.

    // Example: A component needs a specific service from its own injector
    // @Injectable()
    // class MyComponent {
    //   constructor(@Self() private myService: MyService) {}
    // }
    
  • @Host(): This decorator is conceptually tied to the idea of a “host” element or component in a UI framework. In the context of a pure DI hierarchy, it means to search from the current injector up to its closest “host” parent, but not beyond the host. A “host” is often the injector where the component itself is defined or created. For injection-js standalone, it’s typically the injector that directly creates the instance of the class requesting the dependency. It implies searching up the direct parent chain until the injector that first provided the requesting class is found.

    // Example: A service needs a dependency provided by its immediate "parent" module, but not higher up.
    // @Injectable()
    // class ChildService {
    //   constructor(@Host() private parentScopedService: ParentScopedService) {}
    // }
    
  • @SkipSelf(): Starts the search for a dependency from the parent injector, skipping the current injector. This is useful when you want to ensure you get a higher-level instance of a service, even if the current injector also provides it (e.g., to access a global logger even if a local, specialized logger exists).

    // Example: A service needs the root logger, even if a local child logger exists.
    // @Injectable()
    // class MyService {
    //   constructor(@SkipSelf() private globalLogger: Logger) {}
    // }
    

Demonstration with ConsoleWriter:

Let’s illustrate @Self() and @SkipSelf() with a concrete example.

// src/app.ts (or contextual-resolution.ts)
import "reflect-metadata";
import { ReflectiveInjector, Injectable, Inject, Optional, Self, SkipSelf } from 'injection-js';

@Injectable()
export class BaseService {
  constructor() { console.log('BaseService created.'); }
  identity = 'BaseService';
}

@Injectable()
export class FeatureService {
  constructor() { console.log('FeatureService created.'); }
  identity = 'FeatureService';
}

@Injectable()
export class RootComponent {
  // BaseService provided at root
  // FeatureService provided at root
  constructor(
    @Inject(BaseService) private base: BaseService,
    @Inject(FeatureService) private feature: FeatureService
  ) {
    console.log(`RootComponent gets BaseService: ${this.base.identity}`);
    console.log(`RootComponent gets FeatureService: ${this.feature.identity}`);
  }
}

@Injectable()
export class ChildComponent {
  // Child injector provides its own FeatureService.
  // We want to see how decorators change resolution.
  constructor(
    // 1. Get BaseService (provided by parent)
    @Inject(BaseService) private inheritedBase: BaseService,

    // 2. Get FeatureService from current injector only
    @Self() @Inject(FeatureService) private localFeature: FeatureService,

    // 3. Try to get a FeatureService from parent (skip self)
    @SkipSelf() @Inject(FeatureService) private parentFeature: FeatureService,

    // 4. Optionally get a service that might not exist locally or in parent
    @Optional() @Inject('UNKNOWN_SERVICE') private unknownService: any | null
  ) {
    console.log(`ChildComponent gets Inherited BaseService: ${this.inheritedBase.identity}`);
    console.log(`ChildComponent gets Local FeatureService: ${this.localFeature.identity}`);
    console.log(`ChildComponent gets Parent FeatureService (skipped self): ${this.parentFeature.identity}`);
    console.log(`ChildComponent gets Unknown Service (optional): ${this.unknownService}`);
  }
}

// --- Setup Injector Hierarchy ---

// Root Injector
const rootProviders = [
  BaseService,
  FeatureService, // Root FeatureService
  RootComponent
];
const rootInjector = ReflectiveInjector.resolveAndCreate(rootProviders);

const rootComp = rootInjector.get(RootComponent);

// Child Injector
const childProviders = [
  // Child provides its OWN FeatureService
  { provide: FeatureService, useClass: FeatureService },
  ChildComponent
];
const childInjector = ReflectiveInjector.resolveAndCreate(childProviders, rootInjector);

console.log('\n--- Resolving from Child Injector ---');
const childComp = childInjector.get(ChildComponent);

// Verifying instances
console.log('\n--- Instance Verification ---');
console.log('Root BaseService instance:', rootInjector.get(BaseService).identity);
console.log('Root FeatureService instance:', rootInjector.get(FeatureService).identity);
console.log('ChildComponent\'s inherited BaseService === Root BaseService?',
            childComp.inheritedBase === rootInjector.get(BaseService)); // true

console.log('ChildComponent\'s local FeatureService === Root FeatureService?',
            childComp.localFeature === rootInjector.get(FeatureService)); // false, gets its own

console.log('ChildComponent\'s parent FeatureService === Root FeatureService?',
            childComp.parentFeature === rootInjector.get(FeatureService)); // true, explicitly skipped self

Output Breakdown:

  • RootComponent gets instances of BaseService and FeatureService from the rootInjector.
  • ChildComponent’s inheritedBase gets BaseService from the rootInjector (its parent), because BaseService is not provided in childProviders.
  • ChildComponent’s localFeature (with @Self()) explicitly gets the FeatureService instance provided in childProviders, ignoring the parent.
  • ChildComponent’s parentFeature (with @SkipSelf()) explicitly looks only in the parent chain, thus getting the FeatureService from rootInjector.
  • unknownService is null because it’s @Optional() and not provided.

This demonstrates how these decorators give you surgical precision over dependency resolution.

Dynamic Dependency Resolution

While constructor injection is the primary way to get dependencies, sometimes you need to dynamically resolve a dependency at runtime, outside of a constructor. For this, you can inject the Injector itself.

// src/app.ts (or dynamic-resolution.ts)
import "reflect-metadata";
import { ReflectiveInjector, Injectable, Injector } from 'injection-js';

@Injectable()
export class ExpensiveService {
  constructor() { console.log("ExpensiveService created!"); }
  doWork(): string { return "Work done by ExpensiveService"; }
}

@Injectable()
export class ServiceRequiringDynamicDependency {
  constructor(private injector: Injector) {
    // Injecting the Injector itself
    console.log("ServiceRequiringDynamicDependency initialized.");
  }

  // This method might need ExpensiveService only under certain conditions
  getExpensiveResult(): string {
    if (Math.random() > 0.5) { // Simulate a condition
      const expensiveService = this.injector.get(ExpensiveService); // Dynamic resolution
      return expensiveService.doWork();
    }
    return "No expensive work needed right now.";
  }
}

const providers = [
  ExpensiveService,
  ServiceRequiringDynamicDependency
];

const injector = ReflectiveInjector.resolveAndCreate(providers);
const myService = injector.get(ServiceRequiringDynamicDependency);

console.log(myService.getExpensiveResult());
console.log(myService.getExpensiveResult());
console.log(myService.getExpensiveResult());

In this example, ExpensiveService is only instantiated when getExpensiveResult() decides it’s needed, demonstrating dynamic resolution and potential lazy loading. However, be cautious: relying too heavily on injecting the Injector can obscure dependencies and make testing harder. Prefer constructor injection when possible.

Exercises / Mini-Challenges

Exercise 4.1: Building a Custom Validator Chain with Multi-Providers

Objective: Create a system where a form input can have multiple validation rules, and a validator host aggregates them using multi-providers.

  1. Define an InjectionToken for Validator:

    // src/tokens/validator-token.ts
    import { InjectionToken } from 'injection-js';
    
    export interface ValidatorFn {
      (value: string): string | null; // Returns error message or null if valid
    }
    
    export const VALIDATORS = new InjectionToken<ValidatorFn[]>('VALIDATORS');
    
  2. Create some ValidatorFn implementations:

    // src/validators/email-validator.ts
    import { ValidatorFn } from '../tokens/validator-token';
    
    export const emailValidator: ValidatorFn = (value: string): string | null => {
      const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
      return emailRegex.test(value) ? null : 'Invalid email format';
    };
    
    // src/validators/required-validator.ts
    import { ValidatorFn } from '../tokens/validator-token';
    
    export const requiredValidator: ValidatorFn = (value: string): string | null => {
      return value.trim().length > 0 ? null : 'Field is required';
    };
    
    // src/validators/min-length-validator.ts
    import { ValidatorFn } from '../tokens/validator-token';
    
    export const minLengthValidator = (minLength: number): ValidatorFn => {
      return (value: string): string | null => {
        return value.length >= minLength ? null : `Minimum length is ${minLength}`;
      };
    };
    
  3. Create a ValidationService that consumes VALIDATORS:

    // src/services/validation.service.ts
    import { Injectable, Inject } from 'injection-js';
    import { VALIDATORS, ValidatorFn } from '../tokens/validator-token';
    
    @Injectable()
    export class ValidationService {
      constructor(@Inject(VALIDATORS) private validators: ValidatorFn[]) {
        console.log(`ValidationService initialized with ${validators.length} validators.`);
      }
    
      validate(value: string): string[] {
        const errors: string[] = [];
        this.validators.forEach(validator => {
          const error = validator(value);
          if (error) {
            errors.push(error);
          }
        });
        return errors;
      }
    }
    
  4. In app.ts (or multi-validator.ts):

    • Set up an injector.
    • Provide the emailValidator, requiredValidator, and an instance of minLengthValidator(5) using multi: true for VALIDATORS.
    • Get ValidationService and test different inputs.
    // src/multi-validator.ts
    import "reflect-metadata";
    import { ReflectiveInjector, InjectionToken, Injectable, Inject } from 'injection-js';
    
    // --- Validator definitions (copy-paste or import from separate files) ---
    export interface ValidatorFn { (value: string): string | null; }
    export const VALIDATORS = new InjectionToken<ValidatorFn[]>('VALIDATORS');
    
    export const emailValidator: ValidatorFn = (value: string): string | null => {
      const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
      return emailRegex.test(value) ? null : 'Invalid email format';
    };
    export const requiredValidator: ValidatorFn = (value: string): string | null => {
      return value.trim().length > 0 ? null : 'Field is required';
    };
    export const minLengthValidator = (minLength: number): ValidatorFn => {
      return (value: string): string | null => {
        return value.length >= minLength ? null : `Minimum length is ${minLength}`;
      };
    };
    
    @Injectable()
    export class ValidationService {
      constructor(@Inject(VALIDATORS) private validators: ValidatorFn[]) {
        console.log(`ValidationService initialized with ${validators.length} validators.`);
      }
      validate(value: string): string[] {
        const errors: string[] = [];
        this.validators.forEach(validator => {
          const error = validator(value);
          if (error) {
            errors.push(error);
          }
        });
        return errors;
      }
    }
    // --- End Validator definitions ---
    
    const providers = [
      { provide: VALIDATORS, useValue: requiredValidator, multi: true },
      { provide: VALIDATORS, useValue: emailValidator, multi: true },
      { provide: VALIDATORS, useValue: minLengthValidator(5), multi: true },
      ValidationService
    ];
    
    const injector = ReflectiveInjector.resolveAndCreate(providers);
    const validationService = injector.get(ValidationService);
    
    console.log('\nValidating "test@example.com":');
    let errors = validationService.validate('test@example.com');
    console.log(errors.length > 0 ? errors : 'No errors.');
    
    console.log('\nValidating "abc":');
    errors = validationService.validate('abc');
    console.log(errors.length > 0 ? errors : 'No errors.');
    
    console.log('\nValidating "":');
    errors = validationService.validate('');
    console.log(errors.length > 0 ? errors : 'No errors.');
    

    This exercise perfectly demonstrates multi: true for flexible, extensible validation.

Exercise 4.2: Feature Toggle with Optional Dependency

Objective: Implement a feature toggle where a specific “feature service” is only enabled if its provider is present, leveraging @Optional().

  1. Create a FeatureToggleService:

    // src/services/feature-toggle.service.ts
    import { Injectable } from 'injection-js';
    
    @Injectable()
    export class FeatureToggleService {
      isFeatureEnabled(): boolean {
        console.log("FeatureToggleService: Feature is ENABLED.");
        return true;
      }
    }
    
  2. Create a ComponentWithFeature:

    // src/components/component-with-feature.ts
    import { Injectable, Optional, Inject } from 'injection-js';
    import { FeatureToggleService } from '../services/feature-toggle.service';
    
    @Injectable()
    export class ComponentWithFeature {
      constructor(
        @Optional() @Inject(FeatureToggleService) private featureToggle: FeatureToggleService | null
      ) {
        if (this.featureToggle) {
          console.log("ComponentWithFeature: Initialized with feature toggle capability.");
        } else {
          console.log("ComponentWithFeature: Initialized WITHOUT feature toggle capability.");
        }
      }
    
      executeFeature(): string {
        if (this.featureToggle?.isFeatureEnabled()) {
          return "Feature logic executed!";
        } else {
          return "Feature is not available or not enabled.";
        }
      }
    }
    
  3. In app.ts (or feature-toggle.ts):

    • Create an injector where FeatureToggleService IS provided. Get ComponentWithFeature and execute its feature.
    • Create a separate injector where FeatureToggleService is NOT provided. Get ComponentWithFeature from this injector and execute its feature.
    // src/feature-toggle.ts
    import "reflect-metadata";
    import { ReflectiveInjector, Injectable, Optional, Inject } from 'injection-js';
    
    // --- Services & Component (copy-paste or import) ---
    @Injectable()
    export class FeatureToggleService {
      isFeatureEnabled(): boolean {
        console.log("FeatureToggleService: Feature is ENABLED.");
        return true;
      }
    }
    
    @Injectable()
    export class ComponentWithFeature {
      constructor(
        @Optional() @Inject(FeatureToggleService) private featureToggle: FeatureToggleService | null
      ) {
        if (this.featureToggle) {
          console.log("ComponentWithFeature: Initialized with feature toggle capability.");
        } else {
          console.log("ComponentWithFeature: Initialized WITHOUT feature toggle capability.");
        }
      }
      executeFeature(): string {
        if (this.featureToggle?.isFeatureEnabled()) {
          return "Feature logic executed!";
        } else {
          return "Feature is not available or not enabled.";
        }
      }
    }
    // --- End Services & Component ---
    
    console.log("--- Scenario 1: Feature IS Enabled ---");
    const enabledProviders = [
      FeatureToggleService,
      ComponentWithFeature
    ];
    const enabledInjector = ReflectiveInjector.resolveAndCreate(enabledProviders);
    const enabledComp = enabledInjector.get(ComponentWithFeature);
    console.log(enabledComp.executeFeature());
    
    console.log("\n--- Scenario 2: Feature is DISABLED ---");
    const disabledProviders = [
      // FeatureToggleService is intentionally omitted
      ComponentWithFeature
    ];
    const disabledInjector = ReflectiveInjector.resolveAndCreate(disabledProviders);
    const disabledComp = disabledInjector.get(ComponentWithFeature);
    console.log(disabledComp.executeFeature());
    

    This clearly shows how @Optional() allows for flexible, configurable features based on provider presence.

These advanced features, when used judiciously, unlock sophisticated dependency management patterns, making your applications more robust and adaptable.