typed-inject

repository·master·Indexed 20 days ago

https://github.com/nicojs/typed-inject

A tiny, 100% typesafe dependency injection framework for TypeScript (version 5.0.0). It provides compile-time validation for injecting classes, interfaces, or primitives. Key features include support for Singleton and Transient scopes, child injectors for isolated scopes, and an asynchronous disposal mechanism via the Disposable interface to manage resource cleanup.

Tokens
7.6K
Snippets
29
Records
34
Agent score
68%

What's inside typed-inject

  1. Control dependency lifecycle with Scope

    master

    You can control whether a dependency is cached or recreated every time using the third argument of provideFactory or provideClass, which accepts a Scope value.

    • Scope.Singleton (default): The dependency is cached within the specific injector and its children. Subsequent requests for the same dependency return the same instance.
    • Scope.Transient: Caching is disabled. A fresh instance is created every time the dependency is requested.
    // ... imports and setup
    
    const fooProvider = injector
      .provideFactory('log', loggerFactory, Scope.Transient)
      .provideClass('foo', Foo, Scope.Singleton);
    
    const foo = fooProvider.resolve('foo');
    const fooCopy = fooProvider.resolve('foo');
    const log = fooProvider.resolve('foo').log;
    
    console.log(foo === fooCopy); // => true (Singleton)
    // log will be a different instance if provided as Transient
  2. How the Injector works

    master

    The Injector<TContext> is the core interface. It uses a TContext lookup type where keys are injection tokens and values are the types of those tokens.

    • rootInjector: The only built-in implementation. It implements Injector<{}> and provides no tokens (except magic tokens). You use it as a starting point to create child injectors.
    • Child Injectors: Created via provideXXX methods, these extend the parent's capabilities by adding new tokens or overriding existing ones.
  3. How Child Injectors work

    master

    The Injector returned by createInjector() is an empty base injector. To make it useful, you create child injectors by chaining provideXXX methods.

    Each child injector inherits the dependencies of its parent. For example, if a parent injector provides 'foo', a child injector created via .provideValue('bar', ...) can resolve both 'foo' and 'bar'.

    import { createInjector } from 'typed-inject';
    
    function barFactory(foo: number) {
      return foo + 1;
    }
    barFactory.inject = ['foo'] as const;
    
    class Baz {
      constructor(bar: number) {
        console.log(`bar is: ${bar}`);
      }
      static inject = ['bar'] as const;
    }
    
    const childInjector = createInjector()
      .provideValue('foo', 42)
      .provideFactory('bar', barFactory)
      .provideClass('baz', Baz);
    
    function run(baz: Baz) {
      // baz is created!
    }
    run.inject = ['baz'] as const;
    
    childInjector.injectFunction(run);
  4. Implement the Disposable interface

    master

    To allow the Injector to automatically clean up your dependencies, implement a dispose() method. Because of TypeScript's structural typing, you don't need to explicitly implement a Disposable interface; simply having a dispose() method is enough.

    interface Disposable {
      dispose(): void;
    }
  5. Basic Usage of typed-inject

    master

    To use typed-inject, you define dependencies by adding a static inject property to your classes or functions. This property must be a const array of strings representing the names of the dependencies.

    Dependencies are resolved by configuring an injector using provideValue, provideClass, or provideFactory methods, where the names provided must match the strings in the inject array.

    import { createInjector } from 'typed-inject';
    
    interface Logger {
      info(message: string): void;
    }
    
    const logger: Logger = {
      info(message: string) {
        console.log(message);
      },
    };
    
    class HttpClient {
      constructor(private log: Logger) {}
      public static inject = ['logger'] as const;
    }
    
    class MyService {
      constructor(
        private http: HttpClient,
        private log: Logger,
      ) {}
      public static inject = ['httpClient', 'logger'] as const;
    }
    
    const appInjector = createInjector()
      .provideValue('logger', logger)
      .provideClass('httpClient', HttpClient);
    
    const myService = appInjector.injectClass(MyService);
  6. What is an InjectionTarget?

    master

    In typed-inject, an InjectionTarget is the identifier used to look up or provide a dependency within an injector. It can be a Function (often used as a class or constructor), a symbol, a number, a string, or undefined.

    export type InjectionTarget = Function | symbol | number | string | undefined;
  7. Define injectable classes and functions with the Injectable type

    master

    The Injectable type is the core abstraction used to define entities (either classes or functions) that can participate in dependency injection. An Injectable can either have specific dependencies defined via InjectionTokens or have no dependencies at all.

    • With Injections: If an entity has dependencies, it must expose a readonly inject property containing the InjectionTokens used. The constructor (for classes) or the function arguments (for functions) must match the types associated with those tokens via CorrespondingTypes.
    • Without Injections: If an entity has no dependencies, it is treated as a standard constructor new () => R or a standard function () => R.
    // Conceptual representation of how Injectable types are structured
    // An Injectable can be a class or a function, with or without tokens.
    
    type Injectable<TContext, R, Tokens extends readonly InjectionToken<TContext>[]> =
      | InjectableClass<TContext, R, Tokens>
      | InjectableFunction<TContext, R, Tokens>;
  8. Manage lifecycle and disposal with Injector

    master

    Injectors can manage the lifecycle of the objects they provide. When an injector is disposed, it handles the cleanup of provided resources.

    Disposal

    • dispose(): Explicitly disposes the injector. This returns a Promise<void> and is used to clean up instances provided by the injector (e.g., calling .dispose() on objects that implement a disposal interface).

    Scoped Execution Pattern

    A common pattern is to create a child injector for a specific task or scope, use it, and then dispose of it to ensure all resources created within that scope are cleaned up.

    const parentInjector = createInjector().provideValue('foo', 'bar');
    
    for (const task of tasks) {
      let scope;
      try {
        // Create a fresh scope for each task
        scope = parentInjector.createChildInjector();
        
        // Provide a class specific to this task's scope
        const worker = scope.provideClass('worker', WorkerClass).injectClass(WorkerClass);
        worker.handle(task);
      } finally {
        // Dispose the scope, cleaning up 'worker' and other scoped instances
        if (scope) await scope.dispose();
      }
    }
    const parentInjector = createInjector().provideValue('foo', 'bar');
    for(const task of tasks) {
      try {
        const scope = parentInjector.createChildInjector();
        const foo = scope.provideClass('baz', DisposableBaz).injectClass(Foo);
        foo.handle(task);
      } finally {
        await scope.dispose();
      }
    }
  9. Handle InjectionErrors and trace dependency paths

    master

    When an error occurs during the injection process (e.g., a constructor throws), typed-inject wraps the error in an InjectionError. This error provides a detailed path showing exactly which tokens and classes were involved in the failed resolution chain.

    You can catch this error and access the original error via the cause property to inspect the stack trace.

    import { InjectionError } from 'typed-inject';
    
    try {
      createInjector()
        .provideClass('grandChild', GrandChild)
        .provideClass('child', Child)
        .injectClass(Parent);
    } catch (err) {
      if (err instanceof InjectionError) {
        // err.cause contains the original error and the injection path
        console.error(err.cause.stack);
      }
    }
  10. Decorate dependencies using provideFactory and provideClass

    master

    You can implement the decorator pattern by using provideFactory or provideClass to wrap an existing dependency. By providing a factory that takes the original dependency as an argument (and declaring it in the factory's inject property), you can return a decorated version of the object.

    import { createInjector } from 'typed-inject';
    
    class Foo {
      public bar() {
        console.log('bar!');
      }
    }
    
    function fooDecorator(foo: Foo) {
      return {
        bar() {
          console.log('before call');
          foo.bar();
          console.log('after call');
        },
      };
    }
    fooDecorator.inject = ['foo'] as const;
    
    const fooProvider = createInjector()
      .provideClass('foo', Foo)
      .provideFactory('foo', fooDecorator);
    
    const foo = fooProvider.resolve('foo');
    
    foo.bar();
    // => "before call"
    // => "bar!"
    // => "after call"
  11. Create an injector with createInjector()

    master

    Use createInjector() to create a new Injector<{}>. It is recommended to create one injector per application or per request. For unit testing, create a fresh injector for each test (e.g., in a global test setup) to ensure isolation.

    import { createInjector } from 'typed-inject';
    
    const appInjector = createInjector();