Source

decorators.ts

import { DefaultInjectablesConfig, InjectablesKeys } from "./constants";
import { Injectables } from "./Injectables";
import { getInjectKey } from "./utils";
import { InjectableMetadata, InstanceCallback } from "./types";
import { Decoration, Metadata, prop } from "@decaf-ts/decoration";

/**
 * @description Configuration options for the @injectable decorator.
 * @summary Controls lifecycle (singleton vs on-demand) and an optional instance transformation callback executed post-construction.
 * @template T The instance type affected by the callback when provided.
 * @property {boolean} singleton When true, a single instance is shared (singleton). When false, instances are created on demand.
 * @property {InstanceCallback<T>} [callback] Optional hook to transform the instance after it is constructed.
 * @typedef InjectableConfig
 * @memberOf module:injectable-decorators
 */
export type InjectableConfig = {
  singleton: boolean;
  callback?: InstanceCallback<any>;
};

export function injectableBaseDecorator(
  category?: string | Constructor | Partial<InjectableConfig>,
  cfg?: Partial<InjectableConfig>
) {
  cfg =
    cfg ||
    (typeof category === "object"
      ? Object.assign(category as Record<any, any>, DefaultInjectablesConfig)
      : DefaultInjectablesConfig);
  category =
    typeof category === "object"
      ? undefined
      : typeof category === "string"
        ? category
        : typeof category === "function" && category.name
          ? category
          : undefined;
  return function injectableInnerDecorator(original: any) {
    const symbol = Symbol.for(category || original.toString());
    category = category || original.name;

    const metadata: InjectableMetadata = {
      class: category as string,
      symbol: symbol,
    };

    Reflect.defineMetadata(
      getInjectKey(InjectablesKeys.INJECTABLE),
      metadata,
      original
    );
    // the new constructor behaviour
    const newConstructor: any = function (...args: any[]) {
      return Injectables.get<any>(symbol, ...args);
    };

    // copy prototype so instanceof operator still works
    newConstructor.prototype = original.prototype;
    // newConstructor.__proto__ = original.__proto__;
    // Sets the proper constructor name for type verification
    Object.defineProperty(newConstructor, "name", {
      writable: false,
      enumerable: true,
      configurable: false,
      value: original.prototype.constructor.name,
    });

    Reflect.defineMetadata(
      getInjectKey(InjectablesKeys.INJECTABLE),
      metadata,
      newConstructor
    );

    Injectables.register(original, symbol, cfg);
    // return new constructor (will override original)
    return newConstructor;
  };
}

/**
 * @description Generic constructor type for class-like values.
 * @summary Represents any class that can be instantiated with arbitrary arguments, producing an instance of type T.
 * @template T The instance type created by the constructor.
 * @typedef Constructor
 * @example
 * // Using Constructor to type a factory
 * function make<T>(Ctor: Constructor<T>, ...args: any[]): T {
 *   return new Ctor(...args);
 * }
 * @memberOf module:injectable-decorators
 */
export type Constructor<T = any> = { new (...args: any[]): T };

/**
 * @description Decorator that marks a class as available for dependency injection.
 * @summary Defines a class as an injectable that can be retrieved from the registry.
 * When applied to a class, replaces its constructor with one that returns an instance.
 *
 * @return {function(any): any} A decorator function that transforms the class into an injectable.
 *
 * @function injectable
 */
export function injectable(): (original: any) => any;
/**
 * @description Decorator that marks a class as available for dependency injection.
 * @summary Defines a class as an injectable that can be retrieved from the registry.
 * When applied to a class, replaces its constructor with one that returns an instance.
 *
 * @param {string | Constructor} category Defaults to the class category. Useful when minification occurs and names are changed,
 * or when you want to upcast the object to a different type.
 *
 * @return {function(any): any} A decorator function that transforms the class into an injectable.
 *
 * @function injectable
 */
export function injectable(
  category: string | Constructor
): (original: any) => any;
/**
 * @description Decorator that marks a class as available for dependency injection.
 * @summary Defines a class as an injectable that can be retrieved from the registry.
 * When applied to a class, replaces its constructor with one that returns an instance.
 *
 * @param {Partial<InjectableConfig>} cfg=DefaultInjectableConfig Allows overriding the default singleton behavior and adding a callback function.
 *
 * @return {function(any): any} A decorator function that transforms the class into an injectable.
 *
 * @function injectable
 */
export function injectable(
  cfg: Partial<InjectableConfig>
): (original: any) => any;

/**
 * @description Decorator that marks a class as available for dependency injection.
 * @summary Defines a class as an injectable that can be retrieved from the registry.
 * When applied to a class, replaces its constructor with one that returns an instance.
 *
 * @param category Defaults to the class category. Useful when minification occurs and names are changed,
 * or when you want to upcast the object to a different type.
 * @param {Partial<InjectableConfig>} cfg=DefaultInjectableConfig Allows overriding the default singleton behavior and adding a callback function.
 *
 * @return {function(any): any} A decorator function that transforms the class into an injectable.
 *
 * @function injectable
 */
export function injectable(
  category: string | Constructor,
  cfg: Partial<InjectableConfig>
): (original: any) => any;
/**
 * @description Decorator that marks a class as available for dependency injection.
 * @summary Defines a class as an injectable that can be retrieved from the registry.
 * When applied to a class, replaces its constructor with one that returns an instance.
 *
 * @param {string | Constructor} [category] Defaults to the class category. Useful when minification occurs and names are changed,
 * or when you want to upcast the object to a different type.
 * @param {Partial<InjectableConfig>} [cfg=DefaultInjectableConfig] Allows overriding the default singleton behavior and adding a callback function.
 *
 * @return {function(any): any} A decorator function that transforms the class into an injectable.
 *
 * @function injectable
 * @category Class Decorators
 *
 * @mermaid
 * sequenceDiagram
 *   participant Client
 *   participant Decorator
 *   participant Injectables
 *
 *   Client->>Decorator: @injectable()
 *   Decorator->>Decorator: Create new constructor
 *
 *   Note over Decorator: When new instance requested
 *   Decorator->>Injectables: get(category)
 *   alt Instance exists
 *     Injectables-->>Decorator: Return existing instance
 *   else No instance
 *     Decorator->>Injectables: register(original, category)
 *     Decorator->>Injectables: get(category)
 *     Injectables-->>Decorator: Return new instance
 *     opt Has callback
 *       Decorator->>Decorator: Call instanceCallback
 *     end
 *   end
 *   Decorator->>Decorator: Define metadata
 *   Decorator-->>Client: Return instance
 */
export function injectable(
  category?: string | Constructor | Partial<InjectableConfig>,
  cfg?: Partial<InjectableConfig>
) {
  return Decoration.for(InjectablesKeys.INJECTABLE)
    .define({ decorator: injectableBaseDecorator, args: [category, cfg] })
    .apply();
}

/**
 * @description Convenience decorator to register an injectable as a singleton.
 * @summary Wraps {@link injectable} forcing the singleton lifecycle so only one instance is created and reused.
 * @param {string|Constructor} [category] Optional explicit category/symbol source; defaults to the class.
 * @param {Omit<InjectableConfig, "singleton">} [cfg] Additional injectable configuration excluding the singleton flag.
 * @return {function(any): any} A class decorator that registers the target as a singleton injectable.
 * @function singleton
 * @category Class Decorators
 */
export function singleton(
  category?: string | Constructor,
  cfg?: Omit<InjectableConfig, "singleton">
) {
  return injectable(
    category as any,
    Object.assign({}, cfg || {}, { singleton: true })
  );
}

/**
 * @description Convenience decorator to register an injectable as on-demand (non-singleton).
 * @summary Wraps {@link injectable} forcing new instances to be created on every injection or retrieval.
 * @param {string|Constructor} [category] Optional explicit category/symbol source; defaults to the class.
 * @param {Omit<InjectableConfig, "singleton">} [cfg] Additional injectable configuration excluding the singleton flag.
 * @return {function(any): any} A class decorator that registers the target as a non-singleton injectable.
 * @function onDemand
 * @category Class Decorators
 */
export function onDemand(
  category?: string | Constructor,
  cfg?: Omit<InjectableConfig, "singleton">
) {
  return injectable(
    category as any,
    Object.assign({}, cfg || {}, { singleton: false })
  );
}
/**
 * @description Function type for transforming injectable instances before they're injected.
 * @summary Function which transforms a cached {@link injectable} instance before it's injected into a target object.
 *
 * @param {any} injectable The injectable instance to transform
 * @param {any} obj The object the injectable will be injected on
 * @return {any} The transformed injectable instance
 *
 * @typedef {Function} InstanceTransformer
 * @category Decorators
 * @memberOf module:injectable-decorators
 */
export type InstanceTransformer = (injectable: any, obj: any) => any;

/**
 * @description Options for the property-level @inject decorator.
 * @summary Allows specifying constructor arguments and an optional transformer to be applied to the resolved instance.
 * @property {any[]} [args] Optional constructor arguments to use when building the injectable instance.
 * @property {InstanceTransformer} [transformer] Optional function to transform the instance before assignment.
 * @typedef InjectOptions
 * @memberOf module:injectable-decorators
 */
export type InjectOptions = {
  args?: any[];
  transformer?: InstanceTransformer;
};

export function injectBaseDecorator(
  category?: symbol | string | Constructor | Partial<InjectOptions>,
  cfg?: Partial<InjectOptions>
) {
  return function injectInnerDecorator(target: any, propertyKey?: any) {
    const config: InjectOptions = (
      cfg || typeof category === "object" ? category : {}
    ) as InjectOptions;

    if (propertyKey) {
      prop()(target, propertyKey);
    }

    const name: symbol | string | Constructor | undefined =
      (typeof category !== "object" &&
        (category as symbol | string | Constructor)) ||
      Metadata.type(target.constructor, propertyKey);
    if (!name) {
      throw new Error(`Could not get Type from decorator`);
    }

    // prop()(target, propertyKey);
    Reflect.defineMetadata(
      getInjectKey(InjectablesKeys.INJECT),
      {
        injectable: name,
      },
      target,
      propertyKey
    );

    const values = new WeakMap();

    Object.defineProperty(target, propertyKey, {
      configurable: true,
      get(this: any) {
        const descriptor: PropertyDescriptor = Object.getOwnPropertyDescriptor(
          target,
          propertyKey
        ) as PropertyDescriptor;
        if (descriptor.configurable) {
          // let /obj: any;
          Object.defineProperty(this, propertyKey, {
            enumerable: true,
            configurable: false,
            get(this: any) {
              let obj = values.get(this);
              if (obj) return obj;
              obj = Injectables.get(name, ...(config.args || []));
              if (!obj)
                throw new Error(
                  `Could not get Injectable ${name.toString()} to inject in ${target.constructor ? target.constructor.name : target.name}'s ${propertyKey}`
                );
              if (config.transformer)
                try {
                  obj = config.transformer(obj, target);
                } catch (e) {
                  console.error(e);
                }
              values.set(this, obj);

              return obj;
            },
          });
          return this[propertyKey];
        }
      },
    });
  };
}

/**
 * @description Property decorator that injects a dependency into a class property.
 * @summary Allows for the injection of an {@link injectable} decorated dependency into a class property.
 * The property must be typed for the requested dependency. Only concrete classes are supported; generics are not.
 *
 * @return {function(any, any): void} A property decorator function that sets up the dependency injection.
 *
 * @function inject
 */
export function inject(): (target: any, propertyKey: any) => void;
/**
 * @description Property decorator that injects a dependency into a class property.
 * @summary Allows for the injection of an {@link injectable} decorated dependency into a class property.
 * The property must be typed for the requested dependency. Only concrete classes are supported; generics are not.
 *
 * @param {string} category Defaults to the class name derived from the property type. Useful when minification occurs
 * and names are changed, or when you want to upcast the object to a different type.
 * @return {function(any, any): void} A property decorator function that sets up the dependency injection.
 *
 * @function inject
 */
export function inject(
  category: string | Constructor
): (target: any, propertyKey: any) => void;
/**
 * @description Property decorator that injects a dependency into a class property.
 * @summary Allows for the injection of an {@link injectable} decorated dependency into a class property.
 * The property must be typed for the requested dependency. Only concrete classes are supported; generics are not.
 *
 * @param {Partial<InjectOptions>} [cfg={}] Optional function to transform the injectable instance before it's injected, or arguments to pass the constructor when injecting onDemand
 * @return {function(any, any): void} A property decorator function that sets up the dependency injection.
 *
 * @function inject
 */
export function inject(
  cfg: Partial<InjectOptions>
): (target: any, propertyKey: any) => void;
/**
 * @description Property decorator that injects a dependency into a class property.
 * @summary Allows for the injection of an {@link injectable} decorated dependency into a class property.
 * The property must be typed for the requested dependency. Only concrete classes are supported; generics are not.
 *
 * @param {string} category Defaults to the class name derived from the property type. Useful when minification occurs
 * and names are changed, or when you want to upcast the object to a different type.
 * @param {Partial<InjectOptions>} cfg={} Optional function to transform the injectable instance before it's injected, or arguments to pass the constructor when injecting onDemand
 * @return {function(any, any): void} A property decorator function that sets up the dependency injection.
 *
 * @function inject
 */
export function inject(
  category: string | Constructor,
  cfg: Partial<InjectOptions>
): (target: any, propertyKey: any) => void;
/**
 * @description Property decorator that injects a dependency into a class property.
 * @summary Allows for the injection of an {@link injectable} decorated dependency into a class property.
 * The property must be typed for the requested dependency. Only concrete classes are supported; generics are not.
 *
 * Injected properties should be described like so:
 * <pre>
 *     class ClassName {
 *         ...
 *
 *         @inject()
 *         propertyName!: InjectableClass;
 *
 *         ...
 *     }
 * </pre>
 *
 * where InjectableClass is the class you want to inject.
 * Notice the use of '!:' to ensure the transpiler the property will be set outside the constructor but will always be defined.
 * For projects where minification occurs, you should use the category param to ensure the name is the same throughout.
 *
 * @param {string} [category] Defaults to the class name derived from the property type. Useful when minification occurs
 * and names are changed, or when you want to upcast the object to a different type.
 * @param {Partial<InjectOptions>} [cfg={}] Optional function to transform the injectable instance before it's injected, or arguments to pass the constructor when injecting onDemand
 * @return {function(any, any): void} A property decorator function that sets up the dependency injection.
 *
 * @function inject
 * @category Property Decorators
 *
 * @mermaid
 * sequenceDiagram
 *   participant Client
 *   participant Decorator
 *   participant Injectables
 *
 *   Client->>Decorator: @inject()
 *   Decorator->>Decorator: Get type from property
 *   Decorator->>Decorator: Define metadata
 *   Decorator->>Decorator: Define property getter
 *
 *   Note over Decorator: When property accessed
 *   Client->>Decorator: access property
 *   Decorator->>Decorator: Check if instance exists
 *   alt Instance exists in WeakMap
 *     Decorator-->>Client: Return cached instance
 *   else No instance
 *     Decorator->>Injectables: get(name)
 *     alt Injectable found
 *       Injectables-->>Decorator: Return injectable instance
 *       opt Has transformer
 *         Decorator->>Decorator: Call transformer
 *       end
 *       Decorator->>Decorator: Store in WeakMap
 *       Decorator-->>Client: Return instance
 *     else No injectable
 *       Decorator-->>Client: Throw error
 *     end
 *   end
 */
export function inject(
  category?: symbol | string | Constructor | Partial<InjectOptions>,
  cfg?: Partial<InjectOptions>
) {
  return Decoration.for(InjectablesKeys.INJECT)
    .define({ decorator: injectBaseDecorator, args: [category, cfg] })
    .apply();
}