From 126440d7d7cc8256d6cb9ba0567bfac903a1978e Mon Sep 17 00:00:00 2001 From: changchanghwang Date: Tue, 24 Mar 2026 20:27:34 +0900 Subject: [PATCH 1/2] chore: bump version to v1.1.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8cad00b..f2d5225 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "navi-di", - "version": "1.1.0", + "version": "1.1.1", "description": "Dependency injection for standard ECMAScript decorators.", "author": "naviary-sanctuary", "keywords": [ From 59ad2ffa163b047cbfc3e475ac22fa9131091f0e Mon Sep 17 00:00:00 2001 From: changchanghwang Date: Wed, 25 Mar 2026 13:55:58 +0900 Subject: [PATCH 2/2] feat: factory/class/value provider --- README.md | 15 ++++-- src/container/container.ts | 88 ++++++++++++++++++++++++++++---- src/index.ts | 7 +++ src/types/container.ts | 63 ++++++++++++++++++++++- src/types/index.ts | 11 +++- test/container/container.test.ts | 84 ++++++++++++++++++++++++++++++ 6 files changed, 251 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index f4461a3..af4fcaf 100644 --- a/README.md +++ b/README.md @@ -235,12 +235,19 @@ Supported strategies: This is especially useful in tests. -### `container.set(metadata)` +### `container.set(id, valueOrProvider)` -Registers or replaces service metadata for a service identifier. +Registers or replaces a bound value or provider for a service identifier. -This is a low-level API that powers manual registration scenarios and internal tests. -For application-facing code, prefer `@Service()` unless you specifically need to construct metadata yourself. +Supported forms today: + +- `container.set(id, value)` +- `container.set(id, { useValue: value })` +- `container.set(id, { useClass: Class, scope? })` +- `container.set(id, { useFactory: (container) => value, scope? })` + +This is the low-level API for manual value, class, and factory registration. +For decorator-driven classes, prefer `@Service()`. ## Internal architecture diff --git a/src/container/container.ts b/src/container/container.ts index df6780e..756aaa4 100644 --- a/src/container/container.ts +++ b/src/container/container.ts @@ -1,5 +1,13 @@ import { CircularDependencyError, ServiceNotFoundError } from '../errors'; -import type { ContainerIdentifier, Metadata, ServiceIdentifier } from '../types'; +import type { + ClassProvider, + ContainerIdentifier, + FactoryProvider, + Metadata, + ServiceIdentifier, + ServiceProvider, + ValueProvider, +} from '../types'; import { EMPTY_VALUE } from '../types'; import { ContainerRegistry } from './registry'; @@ -27,6 +35,45 @@ export class Container { this.id = id; } + private isValueProvider(provider: T | ServiceProvider): provider is ValueProvider { + return typeof provider === 'object' && provider !== null && 'useValue' in provider; + } + + private isClassProvider(provider: T | ServiceProvider): provider is ClassProvider { + return typeof provider === 'object' && provider !== null && 'useClass' in provider; + } + + private isFactoryProvider(provider: T | ServiceProvider): provider is FactoryProvider { + return typeof provider === 'object' && provider !== null && 'useFactory' in provider; + } + + private getClassProviderMetadata(id: ServiceIdentifier, provider: ClassProvider): Metadata { + const existingMetadata = + this.metadataMap.get(provider.useClass) ?? ContainerRegistry.defaultContainer.metadataMap.get(provider.useClass); + const inheritedInjections = existingMetadata?.Class === provider.useClass ? [...existingMetadata.injections] : []; + const inheritedScope = existingMetadata?.Class === provider.useClass ? existingMetadata.scope : 'container'; + + return { + id, + Class: provider.useClass, + name: provider.useClass.name || String(id), + injections: provider.injections ?? inheritedInjections, + scope: provider.scope ?? inheritedScope, + value: EMPTY_VALUE, + }; + } + + private getFactoryProviderMetadata(id: ServiceIdentifier, provider: FactoryProvider): Metadata { + return { + id, + name: typeof id === 'function' ? id.name : String(id), + injections: [], + scope: provider.scope ?? 'container', + value: EMPTY_VALUE, + factory: provider.useFactory, + }; + } + /** * Returns the default container or a named container. * @@ -98,17 +145,36 @@ export class Container { } /** - * Binds a concrete value to this container. + * Registers a value or provider for a service identifier. * - * Bound values are returned as-is when the same identifier is requested from - * this container. + * Use a plain value to bind an explicit instance, or a provider object to + * resolve by class or factory. * - * @param id The service identifier to bind. - * @param value The value to return for that identifier. + * @param id The service identifier to register. + * @param valueOrProvider The bound value or provider definition. * @returns The current container. */ - public set(id: ServiceIdentifier, value: T) { - this.bindingMap.set(id, value); + public set(id: ServiceIdentifier, value: T): this; + public set(id: ServiceIdentifier, provider: ServiceProvider): this; + public set(id: ServiceIdentifier, valueOrProvider: T | ServiceProvider) { + if (this.isValueProvider(valueOrProvider)) { + this.bindingMap.set(id, valueOrProvider.useValue); + this.metadataMap.delete(id); + return this; + } + + if (this.isClassProvider(valueOrProvider)) { + this.bindingMap.delete(id); + return this.register(this.getClassProviderMetadata(id, valueOrProvider)); + } + + if (this.isFactoryProvider(valueOrProvider)) { + this.bindingMap.delete(id); + return this.register(this.getFactoryProviderMetadata(id, valueOrProvider)); + } + + this.bindingMap.set(id, valueOrProvider); + this.metadataMap.delete(id); return this; } @@ -166,10 +232,10 @@ export class Container { return this.bindingMap.get(id) as T; } - let metadata = this.metadataMap.get(id); + let metadata = this.metadataMap.get(id) as Metadata | undefined; if (!metadata && !this.isDefault()) { - const defaultMetadata = ContainerRegistry.defaultContainer.metadataMap.get(id); + const defaultMetadata = ContainerRegistry.defaultContainer.metadataMap.get(id) as Metadata | undefined; if (!defaultMetadata) { throw new ServiceNotFoundError(id); @@ -208,7 +274,7 @@ export class Container { this.resolvingPath.push(id); try { - const instance = new metadata.Class() as T; + const instance: T = metadata.factory ? metadata.factory(this) : new metadata.Class!(); for (const injection of metadata.injections) { Object.defineProperty(instance, injection.name, { diff --git a/src/index.ts b/src/index.ts index 68c764c..437f801 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,3 +4,10 @@ export { Token } from './tokens'; export type { AbstractConstructable, Constructable } from './types/constructable.ts'; export type { ServiceIdentifier } from './types/service.ts'; +export type { + ClassProvider, + FactoryProvider, + ServiceFactory, + ServiceProvider, + ValueProvider, +} from './types/container.ts'; diff --git a/src/types/container.ts b/src/types/container.ts index 53bd3cb..cf2e75b 100644 --- a/src/types/container.ts +++ b/src/types/container.ts @@ -1,6 +1,7 @@ import type { Constructable } from './constructable'; import type { InjectionMetadata } from './injection'; import type { ServiceIdentifier, ServiceScope } from './service'; +import type { Container } from '../container'; /** * A container identifier used to select the default container or a named container. @@ -12,6 +13,61 @@ export type ContainerIdentifier = string | symbol; */ export const EMPTY_VALUE = Symbol.for('EMPTY_VALUE'); +/** + * A factory function that creates a service using the current container. + */ +export type ServiceFactory = (container: Container) => T; + +/** + * A provider that always resolves to the same explicit value. + */ +export interface ValueProvider { + /** + * The value returned for the registered identifier. + */ + useValue: T; +} + +/** + * A provider that resolves by instantiating a class. + */ +export interface ClassProvider { + /** + * The class instantiated when the service is resolved. + */ + useClass: Constructable; + + /** + * Optional property injection definitions for the registered class. + */ + injections?: InjectionMetadata[]; + + /** + * The lifetime used when the service is resolved. + */ + scope?: ServiceScope; +} + +/** + * A provider that resolves by calling a factory function. + */ +export interface FactoryProvider { + /** + * The factory used to create the resolved value. + */ + useFactory: ServiceFactory; + + /** + * The lifetime used when the service is resolved. + */ + scope?: ServiceScope; +} + +/** + * A provider object accepted by low-level container registration APIs. + */ +export type ServiceProvider = ValueProvider | ClassProvider | FactoryProvider; + /** * Service registration metadata stored by a container. * @@ -26,7 +82,7 @@ export interface Metadata { /** * The class instantiated for this service. */ - Class: Constructable; + Class?: Constructable; /** * The original class or member name, when available. @@ -47,4 +103,9 @@ export interface Metadata { * The cached service instance, or `EMPTY_VALUE` when no instance is cached. */ value: T | typeof EMPTY_VALUE; + + /** + * An optional factory used to create the resolved value. + */ + factory?: ServiceFactory; } diff --git a/src/types/index.ts b/src/types/index.ts index b32e9c1..7335fe7 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,13 @@ export type { AbstractConstructable, Constructable } from './constructable'; export type { ServiceIdentifier, ServiceOption } from './service'; -export { type ContainerIdentifier, type Metadata, EMPTY_VALUE } from './container'; +export { + type ClassProvider, + type ContainerIdentifier, + EMPTY_VALUE, + type FactoryProvider, + type Metadata, + type ServiceFactory, + type ServiceProvider, + type ValueProvider, +} from './container'; export { type InjectionMetadata, INJECTION_KEY } from './injection'; diff --git a/test/container/container.test.ts b/test/container/container.test.ts index df78429..e8f9e81 100644 --- a/test/container/container.test.ts +++ b/test/container/container.test.ts @@ -17,6 +17,8 @@ afterEach(() => { ContainerRegistry.removeContainer('container-reset-fallback'); ContainerRegistry.removeContainer('container-local-has'); ContainerRegistry.removeContainer('container-post-error'); + ContainerRegistry.removeContainer('factory-container-scope'); + ContainerRegistry.removeContainer('factory-singleton-scope'); }); describe('Container', () => { @@ -108,6 +110,88 @@ describe('Container', () => { expect(Container.of().get(BoundService)).toBe(bound); }); + + test('registers a class provider for a custom identifier', () => { + interface Logger { + log(message: string): string; + } + + class ConsoleLogger implements Logger { + public log(message: string) { + return message; + } + } + + Container.of().set('logger', { useClass: ConsoleLogger }); + + const logger = Container.of().get('logger'); + + expect(logger).toBeInstanceOf(ConsoleLogger); + expect(logger.log('hello')).toBe('hello'); + }); + + test('registers a factory provider that can resolve from the current container', () => { + class DependencyService { + public readonly name = 'dependency'; + } + + class CompositeService { + constructor(public readonly dependency: DependencyService) {} + } + + Container.of().register({ + id: DependencyService, + Class: DependencyService, + name: 'DependencyService', + injections: [], + scope: 'container', + value: EMPTY_VALUE, + }); + + Container.of().set(CompositeService, { + useFactory: (container) => new CompositeService(container.get(DependencyService)), + }); + + const composite = Container.of().get(CompositeService); + + expect(composite).toBeInstanceOf(CompositeService); + expect(composite.dependency).toBe(Container.of().get(DependencyService)); + }); + + test('supports container-scoped factory providers per named container', () => { + const firstContainer = Container.of('factory-container-scope'); + let created = 0; + + Container.of().set('request-id', { + useFactory: () => ({ id: ++created }), + scope: 'container', + }); + + const defaultFirst = Container.of().get<{ id: number }>('request-id'); + const defaultSecond = Container.of().get<{ id: number }>('request-id'); + const namedFirst = firstContainer.get<{ id: number }>('request-id'); + const namedSecond = firstContainer.get<{ id: number }>('request-id'); + + expect(defaultFirst).toBe(defaultSecond); + expect(namedFirst).toBe(namedSecond); + expect(defaultFirst).not.toBe(namedFirst); + }); + + test('supports singleton factory providers across containers', () => { + const requestContainer = Container.of('factory-singleton-scope'); + let created = 0; + + Container.of().set('singleton-id', { + useFactory: () => ({ id: ++created }), + scope: 'singleton', + }); + + const defaultInstance = Container.of().get<{ id: number }>('singleton-id'); + const requestInstance = requestContainer.get<{ id: number }>('singleton-id'); + + expect(defaultInstance).toBe(requestInstance); + expect(created).toBe(1); + }); }); describe('remove', () => {