AndreyMork/dibox

View on GitHub
src/Box.ts

Summary

Maintainability
A
0 mins
Test Coverage
A
100%
export type boxShape = Record<boxKey, unknown>;

/**
 * Extracts the shape type from a Box instance.
 *
 * @example
 * ```ts
 * const box = makeBox({
 *   foo: () => 'bar',
 *   count: () => 42
 * });
 *
 * type BoxShape = shapeOf<typeof box>;
 * // BoxShape = { foo: string; count: number }
 * ```
 *
 * @typeParam t - The Box type to extract the shape from
 * @returns The shape type of the Box instance
 */

export type shapeOf<t extends Box<any>> =
    t extends Box<infer shape> ? shape : never;

/**
 * Creates a type representing a view of a Box with only the specified keys.
 *
 * @example
 * ```ts
 * const box = makeBox({
 *   name: () => 'Alice',
 *   age: () => 30,
 *   email: () => 'alice@example.com'
 * });
 *
 * type UserView = viewOf<typeof box, 'name' | 'age'>;
 * // UserView = Box<{ name: string; age: number }>
 * ```
 *
 * @typeParam box - The Box type to create a view from
 * @typeParam keys - The keys to include in the view
 * @returns A Box type containing only the specified keys from the original Box
 */
export type viewOf<box extends Box<any>, keys extends keyof shapeOf<box>> = Box<
    Pick<shapeOf<box>, keys>
>;

export type boxKey = string | symbol;

/**
 * A function that loads a value for a Box dependency.
 *
 * @remarks
 * Load functions have access to the Box instance through their first parameter,
 * allowing them to retrieve other dependencies using `box.get()`.
 *
 * @example
 * ```ts
 * const box = makeBox({
 *   name: () => 'Alice',
 *   greeting: (box) => `Hello ${box.get('name')}!`
 * });
 * ```
 *
 * @typeParam shape - The shape of the Box instance
 * @typeParam value - The type of value returned by this loader
 * @param box - The Box instance containing dependencies
 * @returns The loaded value
 */
export type loadFn<shape extends boxShape, value> = (box: Box<shape>) => value;

export type shapeUpdate<shape extends boxShape, shape2 extends boxShape> = {
    [prop in keyof shape | keyof shape2]: prop extends keyof shape2
        ? shape2[prop]
        : prop extends keyof shape
            ? shape[prop]
            : never;
};

export type patch<shape extends boxShape, parentShape extends boxShape> = {
    [key in keyof shape]: loadFn<parentShape, shape[key]>;
};

export type registry<shape extends boxShape> = {
    [key in keyof shape]?: loadFn<shape, shape[key]>;
};

export type cache<shape extends boxShape> = {
    [key in keyof shape]?: shape[key];
};

export type params<shape extends boxShape> = {
    registry?: registry<shape>;
    cache?: cache<shape>;
};

export { Box, Box as t };

/**
 * A dependency injection container that manages lazy-loaded values.
 *
 * Box provides a type-safe way to define, access, and manage dependencies. Dependencies are
 * defined as factory functions that can access other dependencies through the box instance.
 * Values are lazily evaluated and cached on first access.
 *
 * @typeParam shape - The shape of dependencies contained in this box, mapping keys to their value types
 *
 * @example
 * ```ts
 * const box = makeBox({
 *   config: () => ({ apiUrl: 'https://api.example.com' }),
 *   api: box => new ApiClient(box.get('config').apiUrl),
 *   users: box => box.get('api').getUsers()
 * });
 *
 * // Values are lazily loaded and cached
 * const users = box.get('users'); // API call happens here
 * const sameUsers = box.get('users'); // Returns cached value
 * ```
 *
 * @remarks
 * Box instances are immutable - methods that modify dependencies like `patch()`, `set()`, and `merge()`
 * return new Box instances rather than modifying the original.
 */
class Box<shape extends boxShape> {
    readonly #registry: registry<shape>;
    #cache: cache<shape>;

    constructor(params: params<shape>) {
        this.#registry = params.registry ?? {};
        this.#cache = params.cache ?? {};
    }

    #register<shape2 extends boxShape>(patch: patch<shape2, shape>) {
        type newShape = shapeUpdate<shape, shape2>;
        type newRegistry = registry<newShape>;

        const registry = {
            ...this.#registry,
        } as unknown as newRegistry;

        for (const key in patch) {
            registry[key] = patch[key] as any;
        }

        for (const symKey of Object.getOwnPropertySymbols(patch)) {
            registry[symKey as keyof newShape] = patch[symKey] as any;
        }

        return registry;
    }

    /**
     * Adds a new dependency to the box and returns a new box instance.
     *
     * Creates a new box containing all existing dependencies plus the new one defined by the key and load function.
     * The new box also includes a shallow copy of the current box's cache.
     *
     * @remarks
     * When dependencies need to access each other within the same patch, split into multiple patch/set calls:
     * ```ts
     * box.set('config', () => ({env: 'dev'}))
     *    .set('env', box => box.get('config').env);
     * ```
     *
     * @example
     * Adding a new dependency:
     * ```ts
     * const box = makeBox({ foo: () => 'bar' })
     *   .set('baz', () => 123);
     * ```
     *
     * @example
     * Overriding an existing dependency:
     * ```ts
     * const box = makeBox({
     *   value: () => 'string'
     * }).set('value', () => true); // string -> boolean
     * ```
     *
     * @param key - The key for the new dependency
     * @param loadFn - The factory function to create the new dependency
     * @template key - The type of the key for the new dependency
     * @template value - The type of the value for the new dependency
     * @returns A new Box instance with the added dependency
     * @see {@link patch} For adding multiple dependencies at once
     */
    set<key extends boxKey, value>(key: key, loadFn: loadFn<shape, value>) {
        type shape2 = { [prop in key]: value };
        type newPatch = patch<shape2, shape>;
        const patch = { [key]: loadFn } as newPatch;

        return this.patch(patch);
    }

    /**
     * Updates the box with new dependencies and creates a new box instance.
     *
     * Creates a new box containing all existing dependencies plus new/overridden ones from the patch.
     * The new box also includes a shallow copy of the current box's cache.
     *
     * @remarks
     * When dependencies need to access each other within the same patch, split into multiple patch/set calls:
     * ```ts
     * box.patch({ config: () => ({env: 'dev'}) })
     *    .patch({ env: box => box.get('config').env })
     * ```
     *
     * @example
     * Adding new dependencies:
     * ```ts
     * const box = makeBox({ foo: () => 'bar' })
     *   .patch({
     *     baz: () => 123,
     *     qux: box => `${box.get('foo')}!` // Access parent values
     *   });
     * ```
     *
     * @example
     * Overriding existing dependencies:
     * ```ts
     * const box = makeBox({
     *   value: () => 'string',
     *   count: () => 0
     * }).patch({
     *   value: () => true,    // string -> boolean
     *   count: () => 'zero'   // number -> string
     * });
     * ```
     *
     * @param patch - New dependency definitions to add/override
     * @template shape2 - Type of dependencies being added/overridden
     * @returns A new Box instance with updated dependencies
     * @see {@link clone} For creating a new box with same dependencies but empty cache
     */
    patch<shape2 extends boxShape>(patch: patch<shape2, shape>) {
        type newShape = shapeUpdate<shape, shape2>;

        const registry: registry<newShape> = this.#register<shape2>(patch);
        const cache = { ...this.#cache } as cache<newShape>;

        const newBox = new Box<newShape>({
            registry,
            cache,
        });

        return newBox;
    }

    /**
     * Merges two boxes into a new box, combining their dependencies.
     *
     * Dependencies from the second box override any matching dependencies from the first box.
     * The resulting box has an empty cache - all values will be recomputed on first access.
     *
     * @example
     * ```ts
     * const box1 = makeBox({ foo: () => 'bar' });
     * const box2 = makeBox({ baz: () => 123 });
     *
     * const merged = box1.merge(box2);
     * console.log(merged.get('foo')); // 'bar'
     * console.log(merged.get('baz')); // 123
     * ```
     *
     * @example
     * Overriding dependencies:
     * ```ts
     * const box1 = makeBox({ value: () => 42 });
     * const box2 = makeBox({ value: () => 'override' });
     *
     * const merged = box1.merge(box2);
     * console.log(merged.get('value')); // 'override'
     * ```
     *
     * @param box - The box to merge with this box
     * @template shape2 - Type of dependencies in the second box
     * @returns A new Box instance containing dependencies from both boxes
     */
    merge<shape2 extends boxShape>(box: Box<shape2>) {
        const registry = {
            ...this.#registry,
            ...box.#registry,
        } as registry<shapeUpdate<shape, shape2>>;

        return new Box({
            registry,
        });
    }

    /**
     * Creates a new Box instance with the same dependencies but an empty cache.
     *
     * This method is useful when you want to reuse the same dependency structure
     * but need a fresh instance where all values will be recomputed on first access.
     * Unlike `set` or `patch` which can modify dependencies, `clone` creates an
     * exact copy of the dependency structure.
     *
     * @example
     * ```ts
     * const box1 = makeBox({
     *   random: () => Math.random()
     * });
     *
     * const value1 = box1.get('random'); // Caches first random value
     * const box2 = box1.clone();
     * const value2 = box2.get('random'); // New random value, different from box1
     * ```
     *
     * @returns A new Box instance with identical dependencies but empty cache
     * @see {@link patch} For creating a new box with modified dependencies
     * @see {@link set} For creating a new box with a single modified dependency
     */
    clone() {
        return new Box<shape>({
            registry: this.#registry,
        });
    }

    /**
     * Checks if a key exists in the box's registry.
     *
     * @example
     * ```ts
     * const box = makeBox({ foo: () => 'bar' });
     *
     * box.has('foo');  // true
     * box.has('baz');  // false
     * ```
     *
     * @param key - Key to check for existence
     * @returns Boolean indicating if key exists. Also acts as type predicate narrowing the key type to keyof shape if true
     */
    has(key: keyof shape | boxKey): key is keyof shape {
        return key in this.#registry;
    }

    /**
     * Checks if a key exists in the box's cache.
     *
     * @example
     * ```ts
     * const box = makeBox({ foo: () => 'bar' });
     *
     * // Initially, 'foo' is not cached
     * console.log(box.cached('foo'));  // false
     *
     * // Accessing 'foo' will cache its value
     * const fooValue = box.get('foo'); // 'bar'
     *
     * // Now, 'foo' is cached
     * console.log(box.cached('foo'));  // true
     * ```
     *
     * @param key - Key to check for existence in the cache
     * @returns Boolean indicating if key exists in the cache
     */
    cached(key: keyof shape): boolean {
        return key in this.#cache;
    }

    /**
     * Retrieves the value of a dependency by its key, utilizing the cache if available.
     *
     * Returns a dependency value from the box. The value is cached after the first access,
     * so subsequent calls return the same instance.
     *
     * @remarks
     * For non-cached access, use {@link Box.load} instead.
     *
     * @example
     * ```ts
     * const box = makeBox({ random: () => Math.random() });
     *
     * // Initially, 'random' is not cached
     * console.log(box.cached('random'));  // false
     *
     * // Accessing 'random' will cache its value
     * const randomValue = box.get('random');
     * console.log(randomValue);  // e.g., 0.123456789
     *
     * // Now, 'random' is cached
     * console.log(box.cached('random'));  // true
     *
     * // Accessing 'random' again will return the cached value
     * console.log(box.get('random'));  // e.g., 0.123456789 (same as before)
     * ```
     *
     * @param key - The key of the dependency to retrieve
     * @returns The value of the retrieved dependency
     * @throws {DependencyNotFoundError} If the key does not exist in the registry
     * @see {@link Box.load} For loading the value without using the cache
     */
    get<key extends keyof shape>(key: key): shape[key] {
        if (this.cached(key)) {
            return this.#getCache(key)!;
        }

        const value = this.load(key);
        this.#setCache(key, value);

        return value;
    }

    /**
     * Retrieves the value of a dependency by its key.
     *
     * The value is evaluated each time this method is called. Throws an error if the key is not found.
     *
     * @remarks
     * This method is equivalent to transient dependencies in Awilix in NestJS.
     *
     * @example
     * ```ts
     * const box = makeBox({ foo: () => 'bar', random: () => Math.random() });
     *
     * console.log(box.load('foo'));  // 'bar'
     * console.log(box.load('random'));  // e.g., 0.123456789
     * console.log(box.load('random'));  // e.g., 0.987654321 (different value)
     * ```
     *
     * @param key - The key of the dependency to load
     * @returns The value of the loaded dependency
     * @throws {DependencyNotFoundError} If the key does not exist in the registry
     * @throws {CircularDependencyError} If a circular dependency is detected during loading
     */
    load<key extends keyof shape>(key: key): shape[key] {
        if (this.#keysInProgress.has(key)) {
            throw new CircularDependencyError({
                key: key as boxKey,
                unresolvedKeys: this.#keysInProgress as Set<boxKey>,
                box: this,
            });
        }

        const loadFn = this.#registry[key];

        if (loadFn == null) {
            throw new DependencyNotFoundError(key as boxKey, this);
        }

        this.#keysInProgress.add(key);

        try {
            const value = loadFn(this);
            return value;
        } finally {
            this.#keysInProgress.delete(key);
        }
    }

    #keysInProgress: Set<keyof shape> = new Set();

    /**
     * Clears the cached value for a specific key and immediately reloads it.
     * This is equivalent to calling `clearCache(key)` followed by `get(key)`.
     *
     * @example
     * ```ts
     * const box = makeBox({ random: () => Math.random() });
     *
     * const value1 = box.get('random'); // e.g., 0.123456789
     * const value2 = box.get('random'); // Same value: 0.123456789 (cached)
     *
     * const value3 = box.reload('random'); // New value: e.g., 0.987654321
     * ```
     *
     * @param key - The key whose value should be reloaded
     * @returns The newly loaded value for the given key
     */
    reload<key extends keyof shape>(key: key) {
        return this.clearCache(key).get(key);
    }

    #getCache<key extends keyof shape>(key: key): shape[key] | undefined {
        return this.#cache[key];
    }

    #setCache<key extends keyof shape>(key: key, value: shape[key]) {
        this.#cache[key] = value;
        return this;
    }

    /**
     * Clears all cached values from the box, forcing them to be reloaded on next access.
     *
     * @example
     * ```ts
     * const box = makeBox({ random: () => Math.random() });
     *
     * const value1 = box.get('random'); // e.g., 0.123456789
     * const value2 = box.get('random'); // Same value: 0.123456789 (cached)
     *
     * box.resetCache();
     *
     * const value3 = box.get('random'); // New value: e.g., 0.987654321
     * ```
     *
     * @returns The box instance for method chaining
     */
    resetCache() {
        this.#cache = {};
        return this;
    }

    /**
     * Clears the cached value for a specific key, forcing it to be reloaded on next access.
     *
     * @example
     * ```ts
     * const box = makeBox({ random: () => Math.random() });
     *
     * const value1 = box.get('random'); // e.g., 0.123456789
     * const value2 = box.get('random'); // Same value: 0.123456789 (cached)
     *
     * box.clearCache('random');
     *
     * const value3 = box.get('random'); // New value: e.g., 0.987654321
     * ```
     *
     * @param key - The key whose cached value should be cleared
     * @returns The box instance for method chaining
     */
    clearCache(key: keyof shape) {
        delete this.#cache[key];
        return this;
    }

    /**
     * Returns an array of keys present in the box's registry, preserving their types.
     *
     * @example
     * ```ts
     * const box = makeBox({ foo: () => 'bar', baz: () => 123 });
     * console.log(box.keys());  // ['foo', 'baz']
     * ```
     *
     * @returns An array of keys of type `keyof shape`
     */
    keys() {
        return Object.keys(this.#registry) as (keyof shape)[];
    }

    /**
     * Returns an array of values present in the box, preserving their types.
     *
     * @remarks
     * Values are preloaded before being returned.
     *
     * @example
     * ```ts
     * const box = makeBox({ foo: () => 'bar', baz: () => 123 });
     * console.log(box.values());  // ['bar', 123]
     * ```
     *
     * @returns An array of values of type `shape[keyof shape][]`
     */
    values() {
        this.preload(true);
        return Object.values(this.#cache) as shape[keyof shape][];
    }

    /**
     * Returns an array of [key, value] pairs present in the box's cache, preserving their types.
     *
     * @remarks
     * Values are preloaded before being returned.
     *
     * @example
     * ```ts
     * const box = makeBox({ foo: () => 'bar', baz: () => 123 });
     * console.log(box.entries());  // [['foo', 'bar'], ['baz', 123]]
     * ```
     *
     * @returns An array of [key, value] pairs of type `[keyof shape, shape[keyof shape]][]`
     */
    entries() {
        this.preload(true);
        return Object.entries(this.#cache) as [keyof shape, shape[keyof shape]][];
    }

    /**
     * Returns an iterator for the box, allowing iteration over [key, value] pairs.
     *
     * @remarks
     * This method enables the use of `for...of` loops directly on the box instance.
     *
     * @example
     * ```ts
     * const box = makeBox({ foo: () => 'bar', baz: () => 123 });
     * for (const [key, value] of box) {
     *   console.log(key, value);  // Outputs: 'foo', 'bar' and 'baz', 123
     * }
     * ```
     *
     * @returns An iterator of type `IterableIterator<[keyof shape, shape[keyof shape]]>`
     */
    [Symbol.iterator]() {
        return this.entries()[Symbol.iterator]();
    }

    /**
     * Eagerly loads and caches specified dependencies.
     *
     * When called with a boolean, true preloads all dependencies while false is a no-op.
     * When called with an array of keys, preloads only those specific dependencies.
     *
     * @remarks
     * This is useful when you want to ensure certain dependencies are loaded upfront
     * rather than lazily on first access.
     *
     * @example
     * ```ts
     * const box = makeBox({
     *   config: () => loadConfig(),
     *   db: box => initDatabase(box.get('config'))
     * });
     *
     * // Preload specific dependencies
     * box.preload(['config', 'db']);
     *
     * // Preload everything
     * box.preload(true);
     * ```
     *
     * @param keys - Array of dependency keys to preload, or boolean to preload all/none
     * @returns The box instance for chaining
     */
    preload(flag: boolean): this;
    preload(keys: (keyof shape)[]): this;
    preload(keys: (keyof shape)[] | boolean): this {
        if (typeof keys === 'boolean') {
            return keys ? this.preload(this.keys()) : this;
        }

        for (const key of keys) {
            this.get(key);
        }

        return this;
    }

    /**
     * Returns statistics about the box's dependency cache state.
     *
     * Provides information about:
     * - Total number of dependencies
     * - Number of cached dependencies
     * - Array of cached dependency keys
     * - Array of not yet cached dependency keys
     *
     * @example
     * ```ts
     * const box = makeBox({
     *   name: () => 'Alice',
     *   age: () => 30
     * });
     *
     * box.get('name');
     *
     * console.log(box.stats());
     * // {
     * //   size: 2,
     * //   cached: 1,
     * //   cachedKeys: ['name'],
     * //   notCachedKeys: ['age']
     * // }
     * ```
     *
     * @returns An object containing cache statistics
     */
    stats() {
        const cachedKeys = Object.keys(this.#cache) as (keyof shape)[];
        const notCachedKeys = this.keys().filter(
            (key) => !this.cached(key),
        ) as (keyof shape)[];

        return {
            size: this.keys().length,
            cached: cachedKeys.length,
            cachedKeys,
            notCachedKeys,
        };
    }

    /**
     * Returns a proxy object that automatically retrieves box values through property access.
     *
     * The proxy allows accessing box dependencies using dot notation instead of `.get()`.
     * Values are still lazily loaded and cached on first access.
     *
     * @example
     * ```ts
     * const box = makeBox({
     *   name: () => 'Alice',
     *   age: () => 30
     * });
     *
     * // Instead of box.get('name')
     * console.log(box.proxy.name); // 'Alice'
     * ```
     *
     * @returns A proxy object with the same shape as the box's dependencies
     */
    get proxy(): shape {
        const handler: ProxyHandler<shape> = {
            get: (_target, prop) => {
                return this.get(prop as keyof shape);
            },
        };

        return new Proxy({} as shape, handler);
    }

    /**
     * Converts the box's dependencies into a plain JavaScript object.
     *
     * Forces evaluation of all dependencies and returns them in a standard object format.
     * The returned object will have the same keys as the box, with the resolved values.
     *
     * @example
     * ```ts
     * const box = makeBox({
     *   name: () => 'Alice',
     *   age: () => 30
     * });
     *
     * const obj = box.toJS();
     * // { name: 'Alice', age: 30 }
     * ```
     *
     * @returns A plain object containing all resolved box dependencies
     */
    toJS(): shape {
        return Object.fromEntries(this.entries()) as shape;
    }
}

/**
 * Creates a new Box instance with optional initial values.
 *
 * @example
 * ```ts
 * // Empty box
 * const box = makeBox();
 *
 * // Box with initial values
 * const box = makeBox({
 *   foo: () => 'bar'
 * });
 * ```
 *
 * @param patch - Optional object containing factory functions to initialize box values
 * @returns A new Box instance
 */

// biome-ignore lint/complexity/noBannedTypes: <explanation>
export function makeBox(): Box<{}>;
export function makeBox<shape extends boxShape>(
    // biome-ignore lint/complexity/noBannedTypes: <explanation>
    patch?: patch<shape, {}>,
): Box<shape>;
// biome-ignore lint/complexity/noBannedTypes: <explanation>
export function makeBox<shape extends boxShape>(patch?: patch<shape, {}>) {
    // biome-ignore lint/complexity/noBannedTypes: <explanation>
    const box = new Box<{}>({});

    if (patch == null) {
        return box;
    }

    return box.patch(patch);
}

export class DependencyNotFoundError extends Error {
    readonly key: boxKey;
    readonly box: Box<any>;
    constructor(key: boxKey, box: Box<any>) {
        const knownKeys = box
            .keys()
            .map((key) => key.toString())
            .join(', ');
        const message = `Dependency ${key.toString()} not found. Known keys: ${knownKeys}`;

        super(message);
        this.key = key;
        this.box = box;
        this.name = this.constructor.name;
    }

    get knownKeys() {
        return this.box.keys();
    }
}

export class CircularDependencyError extends Error {
    readonly key: boxKey;
    readonly box: Box<any>;
    readonly unresolvedKeys: Set<boxKey>;

    constructor(params: {
        key: boxKey;
        unresolvedKeys: Set<boxKey>;
        box: Box<any>;
    }) {
        const unresolvedKeys = Array.from(params.unresolvedKeys)
            .map((key) => key.toString())
            .join(', ');

        super(
            `Circular dependency detected for key: ${params.key.toString()}. Unresolved keys: ${unresolvedKeys}`,
        );
        this.key = params.key;
        this.box = params.box;
        this.unresolvedKeys = params.unresolvedKeys;
    }
}