katagami 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,7 +17,7 @@ Lightweight TypeScript DI container with full type inference.
17
17
  | Zero dependencies | No decorators, no reflect-metadata, no polyfills — works with any bundler out of the box |
18
18
  | Full type inference | Types accumulate through method chaining; unregistered tokens are compile-time errors |
19
19
  | Tree-shakeable | Subpath exports (`katagami/scope`, `katagami/disposable`) and `sideEffects: false` for minimal bundle size |
20
- | Captive dependency prevention | Singleton/Transient factories cannot access scoped tokens; caught at compile time |
20
+ | Captive dependency prevention | Singleton/Transient factories cannot access scoped tokens; caught at compile time and runtime in scopes |
21
21
  | Hybrid token strategy | Class tokens for strict type safety, PropertyKey tokens for flexibility |
22
22
  | Interface type map | Pass an interface to `createContainer<T>()` for order-independent registration |
23
23
  | Three lifetimes | Singleton, Transient, and Scoped with child containers |
@@ -26,6 +26,7 @@ Lightweight TypeScript DI container with full type inference.
26
26
  | Async factories | Promise-returning factories are automatically tracked by the type system |
27
27
  | Circular dependency detection | Clear error messages with the full cycle path |
28
28
  | Optional resolution | `tryResolve` returns `undefined` for unregistered tokens instead of throwing |
29
+ | Lazy resolution | Proxy-based deferred instantiation via `lazy()` from `katagami/lazy`; instance created on first access |
29
30
 
30
31
  ## Install
31
32
 
@@ -64,21 +65,45 @@ userService.greet('world');
64
65
 
65
66
  Most TypeScript DI containers rely on decorators, reflect-metadata, or string-based tokens — each bringing trade-offs in tooling compatibility, type safety, or bundle size. Katagami takes a different approach.
66
67
 
68
+ > **Note:** This comparison was researched on 2026-02-09. Features may have changed since then.
69
+
70
+ | Aspect | Katagami | InversifyJS | tsyringe | TypeDI | Awilix | NestJS | Effect | typed-inject |
71
+ | --------------------------------- | ---------------------------------------------------------------- | -------------------------------- | ----------------------------------------------- | --------------------------------------- | ----------------------------------- | ------------------------------------- | --------------------------------- | ------------------------------ |
72
+ | **Runtime requirements** | ✅ None | ❌ reflect-metadata, decorators | ❌ reflect-metadata, decorators | ❌ reflect-metadata, decorators | ✅ None | ❌ reflect-metadata, decorators | ✅ None | ✅ None |
73
+ | **Lifetimes** | ✅ Singleton, Transient, Scoped | ✅ Singleton, Transient, Request | ✅ Singleton, Transient, Resolution / Container | Singleton, Transient (named containers) | ✅ Singleton, Transient, Scoped | ✅ Singleton, Transient, Request | Shared (memoized), Scoped | ❌ Singleton, Transient |
74
+ | **Injection style** | Constructor (explicit factory) | Constructor, Property | Constructor | Constructor, Property | Constructor (proxy / classic) | Constructor | Functional (Tag + Layer) | Constructor (static inject) |
75
+ | **Token types** | Class, PropertyKey, Interface map | Class, String, Symbol | Class, String, Symbol | Class, String, Token\<T\> | String | Class, String, Symbol, InjectionToken | Context.Tag | String literal |
76
+ | **Type safety** | ✅ Compile-time; full inference, captive-dep guard (+ runtime) | ❌ Generic binding types | ❌ Generic types | ❌ Generic types, Token\<T\> | ❌ Cradle interface typing | ❌ Generic types | ✅ Compile-time; R type parameter | ✅ Compile-time; static inject |
77
+ | **Resource cleanup** | ✅ TC39 Symbol.dispose / asyncDispose; await using | Deactivation handlers | container.dispose() | Container.reset() | Disposer functions | Lifecycle hooks (onModuleDestroy) | Scope finalizers; acquireRelease | injector.dispose() |
78
+ | **Tree-shaking** | ✅ Subpath exports; sideEffects: false | ❌ | ❌ | ❌ | ✅ No decorator / metadata overhead | ❌ | ✅ Subpath exports; ESM | ✅ Zero deps; small bundle |
79
+ | **Async factories** | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ |
80
+ | **Optional resolution** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
81
+ | **Multi-binding** | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
82
+ | **Lazy resolution** | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ LazyModuleLoader | ✅ Lazy by design | ❌ |
83
+ | **Conditional bindings** | ✅ Token separation + factory logic + scopes | ✅ Named, tagged, contextual | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
84
+ | **Auto-loading** | ✅ use() module composition (explicit, decorator-free by design) | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
85
+ | **Child containers** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
86
+ | **Module system** | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ | ❌ |
87
+ | **Circular dependency detection** | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ |
88
+ | **Middleware / Interceptors** | ✅ Higher-order factory wrappers | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
89
+ | **Snapshot / Restore** | ✅ Immutable containers; use() for test isolation | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
90
+
67
91
  ### No decorators, no reflect-metadata
68
92
 
69
93
  Decorator-based DI requires `experimentalDecorators` and `emitDecoratorMetadata` compiler options. Modern build tools such as esbuild and Vite (default configuration) do not support `emitDecoratorMetadata`, and the TC39 standard decorators proposal does not include an equivalent for automatic type metadata emission. Katagami depends on none of these — it works with any build tool out of the box.
70
94
 
71
95
  ### Tree-shakeable
72
96
 
73
- Katagami is split into subpath exports. Import only what you use — `katagami/scope` and `katagami/disposable` are completely eliminated from the bundle if not imported. Combined with `sideEffects: false`, bundlers can remove every unused byte.
97
+ Katagami is split into subpath exports. Import only what you use — `katagami/scope`, `katagami/disposable`, and `katagami/lazy` are completely eliminated from the bundle if not imported. Combined with `sideEffects: false`, bundlers can remove every unused byte.
74
98
 
75
99
  ```ts
76
- // Core only — scope and disposable are not included in the bundle
100
+ // Core only — scope, disposable, and lazy are not included in the bundle
77
101
  import { createContainer } from 'katagami';
78
102
 
79
103
  // Import only what you need
80
104
  import { createScope } from 'katagami/scope';
81
105
  import { disposable } from 'katagami/disposable';
106
+ import { lazy } from 'katagami/lazy';
82
107
  ```
83
108
 
84
109
  ### Full type inference from class tokens
@@ -340,17 +365,59 @@ container.resolve(Connection); // OK
340
365
  container.registerSingleton(/* ... */); // Compile-time error
341
366
  ```
342
367
 
368
+ ### Lazy Resolution
369
+
370
+ The `lazy()` function from `katagami/lazy` creates a proxy that defers instance creation until the first property access. This is useful for optimizing startup time or breaking circular dependencies.
371
+
372
+ ```ts
373
+ import { createContainer } from 'katagami';
374
+ import { lazy } from 'katagami/lazy';
375
+
376
+ class HeavyService {
377
+ constructor() {
378
+ // expensive initialization
379
+ }
380
+ process() {
381
+ return 'done';
382
+ }
383
+ }
384
+
385
+ const container = createContainer().registerSingleton(HeavyService, () => new HeavyService());
386
+
387
+ const service = lazy(container, HeavyService);
388
+ // HeavyService is NOT instantiated yet
389
+
390
+ service.process(); // instance created here, then cached
391
+ service.process(); // uses the cached instance
392
+ ```
393
+
394
+ The proxy transparently forwards all property access, method calls, `in` checks, and prototype lookups to the real instance. Methods are automatically bound to the real instance, so `this` works correctly even when destructured.
395
+
396
+ Only **sync class tokens** are supported. Async tokens and PropertyKey tokens are rejected at the type level because Proxy traps are synchronous.
397
+
398
+ `lazy()` works with Container, Scope, DisposableContainer, and DisposableScope:
399
+
400
+ ```ts
401
+ import { createScope } from 'katagami/scope';
402
+
403
+ const root = createContainer().registerScoped(RequestContext, () => new RequestContext());
404
+ const scope = createScope(root);
405
+
406
+ const ctx = lazy(scope, RequestContext); // deferred scoped resolution
407
+ ```
408
+
343
409
  ### Tree Shaking
344
410
 
345
- Katagami uses subpath exports to split functionality into independent entry points. If you only need the core container, `katagami/scope` and `katagami/disposable` are completely excluded from the bundle. The package declares `sideEffects: false`, so bundlers can safely eliminate any unused code.
411
+ Katagami uses subpath exports to split functionality into independent entry points. If you only need the core container, `katagami/scope`, `katagami/disposable`, and `katagami/lazy` are completely excluded from the bundle. The package declares `sideEffects: false`, so bundlers can safely eliminate any unused code.
346
412
 
347
413
  ```ts
348
- // Core only — scope and disposable are not included in the bundle
414
+ // Core only — scope, disposable, and lazy are not included in the bundle
349
415
  import { createContainer } from 'katagami';
350
416
 
351
417
  // Import only what you need
352
418
  import { createScope } from 'katagami/scope';
353
419
  import { disposable } from 'katagami/disposable';
420
+ import { lazy } from 'katagami/lazy';
354
421
  ```
355
422
 
356
423
  ### Interface Type Map
@@ -424,6 +491,24 @@ const container = createContainer()
424
491
  });
425
492
  ```
426
493
 
494
+ Katagami also enforces this rule at runtime within scopes. If a singleton factory attempts to resolve a scoped token — directly or through intermediaries — a `ContainerError` is thrown:
495
+
496
+ ```ts
497
+ import { createContainer } from 'katagami';
498
+ import { createScope } from 'katagami/scope';
499
+
500
+ class DbPool {}
501
+ class RequestContext {}
502
+
503
+ const container = createContainer()
504
+ .registerScoped(RequestContext, () => new RequestContext())
505
+ .registerSingleton(DbPool, r => new DbPool(r.resolve(RequestContext)));
506
+
507
+ const scope = createScope(container);
508
+ scope.resolve(DbPool);
509
+ // ContainerError: Captive dependency detected: scoped token "RequestContext" cannot be resolved inside a singleton factory.
510
+ ```
511
+
427
512
  ### Optional Resolution (tryResolve)
428
513
 
429
514
  When you need to handle optional dependencies or want to check if a token is registered without throwing an error, use `tryResolve`. Unlike `resolve`, it returns `undefined` for unregistered tokens instead of throwing `ContainerError`:
@@ -522,6 +607,10 @@ Resolves and returns the instance for the given token. Behaves the same as `Cont
522
607
 
523
608
  Attempts to resolve the instance for the given token. Returns `undefined` if the token is not registered, instead of throwing. Still throws `ContainerError` for circular dependencies or operations on disposed scopes.
524
609
 
610
+ ### `lazy(source, token)` — `katagami/lazy`
611
+
612
+ Creates a Proxy that defers `resolve()` until the first property access. The resolved instance is cached — subsequent accesses use the cache. Only sync class tokens are supported; async tokens and PropertyKey tokens are rejected at the type level. Works with `Container`, `Scope`, `DisposableContainer`, and `DisposableScope`.
613
+
525
614
  ### `disposable(container)` — `katagami/disposable`
526
615
 
527
616
  Attaches `[Symbol.asyncDispose]` to a `Container` or `Scope`, enabling `await using` syntax. Disposes all owned instances in reverse creation order (LIFO). Calls `[Symbol.asyncDispose]()` or `[Symbol.dispose]()` on each instance that implements them. Idempotent — subsequent calls are no-ops. After disposal, `resolve()` and `createScope()` will throw `ContainerError`. The returned type is narrowed to `DisposableContainer` or `DisposableScope`, which only expose `resolve` and `tryResolve` — registration methods are excluded at the type level.
@@ -23,6 +23,9 @@ export declare function createContainer<T = Record<never, never>, ScopedT = Reco
23
23
  * - Singleton: Creates the instance on the first resolve and returns the cached value thereafter.
24
24
  * - Transient: Creates a new instance via the factory function on every resolve.
25
25
  *
26
+ * Registering the same token multiple times accumulates all factories.
27
+ * `resolve()` returns the last registered instance, while `resolveAll()` returns all.
28
+ *
26
29
  * @template T PropertyKey-based token type map (defined via interface, order-independent)
27
30
  * @template Sync Union of registered sync class constructors (accumulated via chaining, order-dependent)
28
31
  * @template Async Union of registered async class constructors (accumulated via chaining, order-dependent)
@@ -32,7 +35,7 @@ export declare function createContainer<T = Record<never, never>, ScopedT = Reco
32
35
  */
33
36
  export declare class Container<T = Record<never, never>, Sync extends AbstractConstructor = never, Async extends AbstractConstructor = never, ScopedT = Record<never, never>, ScopedSync extends AbstractConstructor = never, ScopedAsync extends AbstractConstructor = never> {
34
37
  private readonly registrations;
35
- private readonly instances;
38
+ private readonly singletonCache;
36
39
  private readonly resolvingTokens;
37
40
  private disposed;
38
41
  /**
@@ -46,6 +49,8 @@ export declare class Container<T = Record<never, never>, Sync extends AbstractCo
46
49
  * Register a factory function as a singleton for the given token.
47
50
  *
48
51
  * Creates the instance on the first resolve and returns the cached value thereafter.
52
+ * If the same token is registered multiple times, all factories are accumulated.
53
+ * `resolve()` returns the last registered instance; `resolveAll()` returns all.
49
54
  *
50
55
  * @param token Any value to use as a token
51
56
  * @param factory Factory function that receives a resolver and returns an instance
@@ -59,6 +64,8 @@ export declare class Container<T = Record<never, never>, Sync extends AbstractCo
59
64
  * Register a factory function as transient for the given token.
60
65
  *
61
66
  * Creates a new instance via the factory function on every resolve.
67
+ * If the same token is registered multiple times, all factories are accumulated.
68
+ * `resolve()` returns the last registered instance; `resolveAll()` returns all.
62
69
  *
63
70
  * @param token Any value to use as a token
64
71
  * @param factory Factory function that receives a resolver and returns an instance
@@ -86,8 +93,8 @@ export declare class Container<T = Record<never, never>, Sync extends AbstractCo
86
93
  /**
87
94
  * Apply all registrations from another container (module) to this container.
88
95
  *
89
- * Copies only registration entries (factory + lifetime). Singleton instance caches
90
- * are not shared — each container manages its own.
96
+ * Copies registration entries (factory + lifetime) by replacing existing entries for each token.
97
+ * Singleton instance caches are not shared — each container manages its own.
91
98
  *
92
99
  * @param source A container whose registrations will be copied into this container
93
100
  * @returns The container for method chaining
@@ -96,6 +103,7 @@ export declare class Container<T = Record<never, never>, Sync extends AbstractCo
96
103
  /**
97
104
  * Resolve an instance for the given token.
98
105
  *
106
+ * Returns the instance from the last registered factory for the token.
99
107
  * For singleton registrations, creates the instance on the first call and caches it.
100
108
  * For transient registrations, creates a new instance on every call.
101
109
  *
@@ -120,8 +128,36 @@ export declare class Container<T = Record<never, never>, Sync extends AbstractCo
120
128
  tryResolve<K extends keyof T>(token: K): T[K] | undefined;
121
129
  tryResolve<V>(token: AbstractConstructor<V>): V | Promise<V> | undefined;
122
130
  tryResolve(token: PropertyKey): unknown;
131
+ /**
132
+ * Resolve all instances for the given token.
133
+ *
134
+ * Returns an array of instances from all registered factories for the token,
135
+ * in registration order.
136
+ *
137
+ * @param token A registered token
138
+ * @returns An array of instances associated with the token
139
+ * @throws ContainerError if the token is not registered
140
+ */
141
+ resolveAll<V>(token: AbstractConstructor<V> & Async): Promise<V>[];
142
+ resolveAll<V>(token: AbstractConstructor<V> & Sync): V[];
143
+ resolveAll<K extends keyof T>(token: K): T[K][];
144
+ /**
145
+ * Try to resolve all instances for the given token.
146
+ *
147
+ * Returns `undefined` instead of throwing when the token is not registered.
148
+ * Other errors (circular dependency, disposed container) are still thrown.
149
+ *
150
+ * @param token A token to resolve
151
+ * @returns An array of instances associated with the token, or `undefined` if not registered
152
+ */
153
+ tryResolveAll<V>(token: AbstractConstructor<V> & Async): Promise<V>[] | undefined;
154
+ tryResolveAll<V>(token: AbstractConstructor<V> & Sync): V[] | undefined;
155
+ tryResolveAll<K extends keyof T>(token: K): T[K][] | undefined;
156
+ tryResolveAll<V>(token: AbstractConstructor<V>): (V | Promise<V>)[] | undefined;
157
+ tryResolveAll(token: PropertyKey): unknown;
123
158
  /**
124
159
  * Internal resolution logic shared by resolve and tryResolve.
160
+ * Resolves the last registered factory for the token.
125
161
  *
126
162
  * @param token Token to resolve
127
163
  * @param required If true, throws when the token is not registered. If false, returns undefined.
@@ -129,7 +165,16 @@ export declare class Container<T = Record<never, never>, Sync extends AbstractCo
129
165
  */
130
166
  private resolveToken;
131
167
  /**
132
- * Add a registration entry.
168
+ * Internal resolution logic shared by resolveAll and tryResolveAll.
169
+ * Resolves all registered factories for the token.
170
+ *
171
+ * @param token Token to resolve
172
+ * @param required If true, throws when the token is not registered. If false, returns undefined.
173
+ * @returns An array of resolved instances, or undefined if not registered and required is false
174
+ */
175
+ private resolveAllTokens;
176
+ /**
177
+ * Add a registration entry. Accumulates registrations for the same token.
133
178
  *
134
179
  * @param token Token
135
180
  * @param factory Factory function
@@ -44,7 +44,7 @@ function disposable(container) {
44
44
  return;
45
45
  }
46
46
  internals.markDisposed();
47
- const instances = [...internals.ownInstances.values()].reverse();
47
+ const instances = [...internals.ownCache.values()].reverse();
48
48
  const errors = [];
49
49
  for (const instance of instances) {
50
50
  try {
@@ -63,7 +63,7 @@ function disposable(container) {
63
63
  errors.push(error);
64
64
  }
65
65
  }
66
- internals.ownInstances.clear();
66
+ internals.ownCache.clear();
67
67
  if (errors.length > 0) {
68
68
  throw new AggregateError(errors, "One or more errors occurred during disposal.");
69
69
  }
@@ -5,7 +5,7 @@ import type { Scope } from '../scope';
5
5
  /**
6
6
  * A container wrapped with `disposable()`.
7
7
  *
8
- * Only `resolve` and `tryResolve` are available at the type level.
8
+ * Only `resolve`, `tryResolve`, `resolveAll`, and `tryResolveAll` are available at the type level.
9
9
  * Registration methods (`registerSingleton`, `registerTransient`, `registerScoped`, `use`)
10
10
  * are excluded, preventing accidental registration on a potentially-disposed container.
11
11
  *
@@ -22,7 +22,7 @@ export interface DisposableContainer<T = Record<never, never>, Sync extends Abst
22
22
  /**
23
23
  * A scope wrapped with `disposable()`.
24
24
  *
25
- * Only `resolve` and `tryResolve` are available at the type level.
25
+ * Only `resolve`, `tryResolve`, `resolveAll`, and `tryResolveAll` are available at the type level.
26
26
  *
27
27
  * @template T PropertyKey-based token type map
28
28
  * @template Sync Union of registered sync class constructors
@@ -41,7 +41,7 @@ export interface DisposableScope<T = Record<never, never>, Sync extends Abstract
41
41
  * Disposes owned instances in reverse creation order (LIFO), calling
42
42
  * `[Symbol.asyncDispose]()` or `[Symbol.dispose]()` on each instance that implements them.
43
43
  *
44
- * The returned type is narrowed to only expose `resolve` and `tryResolve`,
44
+ * The returned type is narrowed to only expose `resolve`, `tryResolve`, `resolveAll`, and `tryResolveAll`,
45
45
  * preventing registration methods from being called on a potentially-disposed container.
46
46
  *
47
47
  * @param container A Container or Scope to make disposable
@@ -10,7 +10,7 @@ function disposable(container) {
10
10
  return;
11
11
  }
12
12
  internals.markDisposed();
13
- const instances = [...internals.ownInstances.values()].reverse();
13
+ const instances = [...internals.ownCache.values()].reverse();
14
14
  const errors = [];
15
15
  for (const instance of instances) {
16
16
  try {
@@ -29,7 +29,7 @@ function disposable(container) {
29
29
  errors.push(error);
30
30
  }
31
31
  }
32
- internals.ownInstances.clear();
32
+ internals.ownCache.clear();
33
33
  if (errors.length > 0) {
34
34
  throw new AggregateError(errors, "One or more errors occurred during disposal.");
35
35
  }
package/dist/index.cjs CHANGED
@@ -78,22 +78,22 @@ function createContainer() {
78
78
 
79
79
  class Container {
80
80
  registrations;
81
- instances;
81
+ singletonCache;
82
82
  resolvingTokens;
83
83
  disposed = false;
84
84
  [INTERNALS];
85
85
  constructor() {
86
86
  this.registrations = new Map;
87
- this.instances = new Map;
87
+ this.singletonCache = new Map;
88
88
  this.resolvingTokens = new Set;
89
89
  this[INTERNALS] = {
90
- instances: this.instances,
91
90
  isDisposed: () => this.disposed,
92
91
  markDisposed: () => {
93
92
  this.disposed = true;
94
93
  },
95
- ownInstances: this.instances,
96
- registrations: this.registrations
94
+ ownCache: this.singletonCache,
95
+ registrations: this.registrations,
96
+ singletonCache: this.singletonCache
97
97
  };
98
98
  }
99
99
  registerSingleton(token, factory) {
@@ -106,8 +106,8 @@ class Container {
106
106
  return this.addRegistration(token, factory, "scoped");
107
107
  }
108
108
  use(source) {
109
- for (const [token, registration] of source[INTERNALS].registrations) {
110
- this.registrations.set(token, registration);
109
+ for (const [token, registrations] of source[INTERNALS].registrations) {
110
+ this.registrations.set(token, [...registrations]);
111
111
  }
112
112
  return this;
113
113
  }
@@ -117,21 +117,28 @@ class Container {
117
117
  tryResolve(token) {
118
118
  return this.resolveToken(token, false);
119
119
  }
120
+ resolveAll(token) {
121
+ return this.resolveAllTokens(token, true);
122
+ }
123
+ tryResolveAll(token) {
124
+ return this.resolveAllTokens(token, false);
125
+ }
120
126
  resolveToken(token, required) {
121
127
  if (this.disposed) {
122
128
  throw new ContainerError("Cannot resolve from a disposed container.");
123
129
  }
124
- const cached = this.instances.get(token);
125
- if (cached !== undefined) {
126
- return cached;
127
- }
128
- const registration = this.registrations.get(token);
129
- if (registration === undefined) {
130
+ const registrations = this.registrations.get(token);
131
+ if (registrations === undefined || registrations.length === 0) {
130
132
  if (required) {
131
133
  throw new ContainerError(`Token "${tokenToString(token)}" is not registered.`);
132
134
  }
133
135
  return;
134
136
  }
137
+ const registration = registrations[registrations.length - 1];
138
+ const cached = this.singletonCache.get(registration);
139
+ if (cached !== undefined) {
140
+ return cached;
141
+ }
135
142
  if (registration.lifetime === "scoped") {
136
143
  throw new ContainerError(`Cannot resolve scoped token "${tokenToString(token)}" from the root container. Use createScope() to create a scope first.`);
137
144
  }
@@ -142,15 +149,55 @@ class Container {
142
149
  try {
143
150
  const instance = registration.factory(this);
144
151
  if (registration.lifetime === "singleton") {
145
- this.instances.set(token, instance);
152
+ this.singletonCache.set(registration, instance);
146
153
  }
147
154
  return instance;
148
155
  } finally {
149
156
  this.resolvingTokens.delete(token);
150
157
  }
151
158
  }
159
+ resolveAllTokens(token, required) {
160
+ if (this.disposed) {
161
+ throw new ContainerError("Cannot resolve from a disposed container.");
162
+ }
163
+ const registrations = this.registrations.get(token);
164
+ if (registrations === undefined || registrations.length === 0) {
165
+ if (required) {
166
+ throw new ContainerError(`Token "${tokenToString(token)}" is not registered.`);
167
+ }
168
+ return;
169
+ }
170
+ if (this.resolvingTokens.has(token)) {
171
+ throw new ContainerError(`Circular dependency detected: ${buildCircularPath(this.resolvingTokens, token)}`);
172
+ }
173
+ this.resolvingTokens.add(token);
174
+ try {
175
+ return registrations.map((registration) => {
176
+ const reg = registration;
177
+ const cached = this.singletonCache.get(registration);
178
+ if (cached !== undefined) {
179
+ return cached;
180
+ }
181
+ if (reg.lifetime === "scoped") {
182
+ throw new ContainerError(`Cannot resolve scoped token "${tokenToString(token)}" from the root container. Use createScope() to create a scope first.`);
183
+ }
184
+ const instance = reg.factory(this);
185
+ if (reg.lifetime === "singleton") {
186
+ this.singletonCache.set(registration, instance);
187
+ }
188
+ return instance;
189
+ });
190
+ } finally {
191
+ this.resolvingTokens.delete(token);
192
+ }
193
+ }
152
194
  addRegistration(token, factory, lifetime) {
153
- this.registrations.set(token, { factory, lifetime });
195
+ const existing = this.registrations.get(token);
196
+ if (existing !== undefined) {
197
+ existing.push({ factory, lifetime });
198
+ } else {
199
+ this.registrations.set(token, [{ factory, lifetime }]);
200
+ }
154
201
  return this;
155
202
  }
156
203
  }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { Container, createContainer } from './container';
2
2
  export type { DisposableContainer, DisposableScope, disposable } from './disposable';
3
3
  export { ContainerError } from './error';
4
+ export type { lazy } from './lazy';
4
5
  export type { Resolver } from './resolver';
5
6
  export type { createScope, Scope } from './scope';
package/dist/index.js CHANGED
@@ -14,22 +14,22 @@ function createContainer() {
14
14
 
15
15
  class Container {
16
16
  registrations;
17
- instances;
17
+ singletonCache;
18
18
  resolvingTokens;
19
19
  disposed = false;
20
20
  [INTERNALS];
21
21
  constructor() {
22
22
  this.registrations = new Map;
23
- this.instances = new Map;
23
+ this.singletonCache = new Map;
24
24
  this.resolvingTokens = new Set;
25
25
  this[INTERNALS] = {
26
- instances: this.instances,
27
26
  isDisposed: () => this.disposed,
28
27
  markDisposed: () => {
29
28
  this.disposed = true;
30
29
  },
31
- ownInstances: this.instances,
32
- registrations: this.registrations
30
+ ownCache: this.singletonCache,
31
+ registrations: this.registrations,
32
+ singletonCache: this.singletonCache
33
33
  };
34
34
  }
35
35
  registerSingleton(token, factory) {
@@ -42,8 +42,8 @@ class Container {
42
42
  return this.addRegistration(token, factory, "scoped");
43
43
  }
44
44
  use(source) {
45
- for (const [token, registration] of source[INTERNALS].registrations) {
46
- this.registrations.set(token, registration);
45
+ for (const [token, registrations] of source[INTERNALS].registrations) {
46
+ this.registrations.set(token, [...registrations]);
47
47
  }
48
48
  return this;
49
49
  }
@@ -53,21 +53,28 @@ class Container {
53
53
  tryResolve(token) {
54
54
  return this.resolveToken(token, false);
55
55
  }
56
+ resolveAll(token) {
57
+ return this.resolveAllTokens(token, true);
58
+ }
59
+ tryResolveAll(token) {
60
+ return this.resolveAllTokens(token, false);
61
+ }
56
62
  resolveToken(token, required) {
57
63
  if (this.disposed) {
58
64
  throw new ContainerError("Cannot resolve from a disposed container.");
59
65
  }
60
- const cached = this.instances.get(token);
61
- if (cached !== undefined) {
62
- return cached;
63
- }
64
- const registration = this.registrations.get(token);
65
- if (registration === undefined) {
66
+ const registrations = this.registrations.get(token);
67
+ if (registrations === undefined || registrations.length === 0) {
66
68
  if (required) {
67
69
  throw new ContainerError(`Token "${tokenToString(token)}" is not registered.`);
68
70
  }
69
71
  return;
70
72
  }
73
+ const registration = registrations[registrations.length - 1];
74
+ const cached = this.singletonCache.get(registration);
75
+ if (cached !== undefined) {
76
+ return cached;
77
+ }
71
78
  if (registration.lifetime === "scoped") {
72
79
  throw new ContainerError(`Cannot resolve scoped token "${tokenToString(token)}" from the root container. Use createScope() to create a scope first.`);
73
80
  }
@@ -78,15 +85,55 @@ class Container {
78
85
  try {
79
86
  const instance = registration.factory(this);
80
87
  if (registration.lifetime === "singleton") {
81
- this.instances.set(token, instance);
88
+ this.singletonCache.set(registration, instance);
82
89
  }
83
90
  return instance;
84
91
  } finally {
85
92
  this.resolvingTokens.delete(token);
86
93
  }
87
94
  }
95
+ resolveAllTokens(token, required) {
96
+ if (this.disposed) {
97
+ throw new ContainerError("Cannot resolve from a disposed container.");
98
+ }
99
+ const registrations = this.registrations.get(token);
100
+ if (registrations === undefined || registrations.length === 0) {
101
+ if (required) {
102
+ throw new ContainerError(`Token "${tokenToString(token)}" is not registered.`);
103
+ }
104
+ return;
105
+ }
106
+ if (this.resolvingTokens.has(token)) {
107
+ throw new ContainerError(`Circular dependency detected: ${buildCircularPath(this.resolvingTokens, token)}`);
108
+ }
109
+ this.resolvingTokens.add(token);
110
+ try {
111
+ return registrations.map((registration) => {
112
+ const reg = registration;
113
+ const cached = this.singletonCache.get(registration);
114
+ if (cached !== undefined) {
115
+ return cached;
116
+ }
117
+ if (reg.lifetime === "scoped") {
118
+ throw new ContainerError(`Cannot resolve scoped token "${tokenToString(token)}" from the root container. Use createScope() to create a scope first.`);
119
+ }
120
+ const instance = reg.factory(this);
121
+ if (reg.lifetime === "singleton") {
122
+ this.singletonCache.set(registration, instance);
123
+ }
124
+ return instance;
125
+ });
126
+ } finally {
127
+ this.resolvingTokens.delete(token);
128
+ }
129
+ }
88
130
  addRegistration(token, factory, lifetime) {
89
- this.registrations.set(token, { factory, lifetime });
131
+ const existing = this.registrations.get(token);
132
+ if (existing !== undefined) {
133
+ existing.push({ factory, lifetime });
134
+ } else {
135
+ this.registrations.set(token, [{ factory, lifetime }]);
136
+ }
90
137
  return this;
91
138
  }
92
139
  }
@@ -14,12 +14,12 @@ export declare const INTERNALS: unique symbol;
14
14
  * @internal
15
15
  */
16
16
  export interface ContainerInternals {
17
- /** All registrations (singleton / transient / scoped). */
18
- readonly registrations: Map<unknown, Registration>;
19
- /** Instance cache used during resolution (Container: singletons, Scope: singletonInstances). */
20
- readonly instances: Map<unknown, unknown>;
21
- /** Instances owned by this container / scope (disposal target). */
22
- readonly ownInstances: Map<unknown, unknown>;
17
+ /** All registrations (singleton / transient / scoped). Each token maps to an array of registrations. */
18
+ readonly registrations: Map<unknown, Registration[]>;
19
+ /** Singleton cache keyed by Registration object (Container: singletons, Scope: shared with parent). */
20
+ readonly singletonCache: Map<Registration, unknown>;
21
+ /** Instances owned by this container / scope (disposal target), keyed by Registration object. */
22
+ readonly ownCache: Map<Registration, unknown>;
23
23
  /** Whether this container / scope has been disposed. */
24
24
  isDisposed(): boolean;
25
25
  /** Mark this container / scope as disposed. */
@@ -0,0 +1,88 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __getOwnPropNames = Object.getOwnPropertyNames;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
5
+ var __moduleCache = /* @__PURE__ */ new WeakMap;
6
+ var __toCommonJS = (from) => {
7
+ var entry = __moduleCache.get(from), desc;
8
+ if (entry)
9
+ return entry;
10
+ entry = __defProp({}, "__esModule", { value: true });
11
+ if (from && typeof from === "object" || typeof from === "function")
12
+ __getOwnPropNames(from).map((key) => !__hasOwnProp.call(entry, key) && __defProp(entry, key, {
13
+ get: () => from[key],
14
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
15
+ }));
16
+ __moduleCache.set(from, entry);
17
+ return entry;
18
+ };
19
+ var __export = (target, all) => {
20
+ for (var name in all)
21
+ __defProp(target, name, {
22
+ get: all[name],
23
+ enumerable: true,
24
+ configurable: true,
25
+ set: (newValue) => all[name] = () => newValue
26
+ });
27
+ };
28
+
29
+ // src/lazy/index.ts
30
+ var exports_lazy = {};
31
+ __export(exports_lazy, {
32
+ lazy: () => lazy
33
+ });
34
+ module.exports = __toCommonJS(exports_lazy);
35
+ function lazy(source, token) {
36
+ let instance;
37
+ let resolved = false;
38
+ const ensureResolved = () => {
39
+ if (!resolved) {
40
+ instance = source.resolve(token);
41
+ resolved = true;
42
+ }
43
+ return instance;
44
+ };
45
+ const proxyTarget = Object.create(null);
46
+ return new Proxy(proxyTarget, {
47
+ defineProperty(_, prop, desc) {
48
+ return Reflect.defineProperty(ensureResolved(), prop, desc);
49
+ },
50
+ deleteProperty(_, prop) {
51
+ return Reflect.deleteProperty(ensureResolved(), prop);
52
+ },
53
+ get(_, prop) {
54
+ const target = ensureResolved();
55
+ const value = Reflect.get(target, prop, target);
56
+ if (typeof value === "function") {
57
+ return value.bind(target);
58
+ }
59
+ return value;
60
+ },
61
+ getOwnPropertyDescriptor(_, prop) {
62
+ return Reflect.getOwnPropertyDescriptor(ensureResolved(), prop);
63
+ },
64
+ getPrototypeOf() {
65
+ return Reflect.getPrototypeOf(ensureResolved());
66
+ },
67
+ has(_, prop) {
68
+ return Reflect.has(ensureResolved(), prop);
69
+ },
70
+ isExtensible() {
71
+ return Reflect.isExtensible(ensureResolved());
72
+ },
73
+ ownKeys() {
74
+ return Reflect.ownKeys(ensureResolved());
75
+ },
76
+ preventExtensions() {
77
+ Reflect.preventExtensions(ensureResolved());
78
+ Reflect.preventExtensions(proxyTarget);
79
+ return true;
80
+ },
81
+ set(_, prop, value) {
82
+ return Reflect.set(ensureResolved(), prop, value, ensureResolved());
83
+ },
84
+ setPrototypeOf(_, proto) {
85
+ return Reflect.setPrototypeOf(ensureResolved(), proto);
86
+ }
87
+ });
88
+ }
@@ -0,0 +1,35 @@
1
+ import type { Container } from '../container';
2
+ import type { DisposableContainer, DisposableScope } from '../disposable';
3
+ import type { AbstractConstructor } from '../resolver';
4
+ import type { Scope } from '../scope';
5
+ /**
6
+ * Create a lazy proxy that defers resolution until the first property access.
7
+ *
8
+ * The returned object looks and behaves like `V`, but the underlying instance
9
+ * is not created until a property is read, written, or otherwise accessed.
10
+ * Once resolved the instance is cached — subsequent accesses hit the cache.
11
+ *
12
+ * Only **sync class tokens** are supported. Async tokens and PropertyKey tokens
13
+ * are rejected at the type level.
14
+ *
15
+ * @param source A Container, Scope, DisposableContainer, or DisposableScope
16
+ * @param token A sync class constructor token
17
+ * @returns A proxy that transparently forwards to the lazily-resolved instance
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * import { createContainer } from 'katagami';
22
+ * import { lazy } from 'katagami/lazy';
23
+ *
24
+ * const container = createContainer()
25
+ * .registerSingleton(HeavyService, () => new HeavyService());
26
+ *
27
+ * const service = lazy(container, HeavyService);
28
+ * // Instance is NOT created yet
29
+ * service.doSomething(); // resolved here, then cached
30
+ * ```
31
+ */
32
+ export declare function lazy<T, Sync extends AbstractConstructor, Async extends AbstractConstructor, ScopedT, ScopedSync extends AbstractConstructor, ScopedAsync extends AbstractConstructor, V>(source: Container<T, Sync, Async, ScopedT, ScopedSync, ScopedAsync>, token: AbstractConstructor<V> & Sync): V;
33
+ export declare function lazy<T, Sync extends AbstractConstructor, Async extends AbstractConstructor, ScopedT, ScopedSync extends AbstractConstructor, ScopedAsync extends AbstractConstructor, V>(source: Scope<T, Sync, Async, ScopedT, ScopedSync, ScopedAsync>, token: AbstractConstructor<V> & (Sync | ScopedSync)): V;
34
+ export declare function lazy<T, Sync extends AbstractConstructor, Async extends AbstractConstructor, ScopedT, ScopedSync extends AbstractConstructor, ScopedAsync extends AbstractConstructor, V>(source: DisposableContainer<T, Sync, Async, ScopedT, ScopedSync, ScopedAsync>, token: AbstractConstructor<V> & Sync): V;
35
+ export declare function lazy<T, Sync extends AbstractConstructor, Async extends AbstractConstructor, ScopedT, ScopedSync extends AbstractConstructor, ScopedAsync extends AbstractConstructor, V>(source: DisposableScope<T, Sync, Async, ScopedT, ScopedSync, ScopedAsync>, token: AbstractConstructor<V> & (Sync | ScopedSync)): V;
@@ -0,0 +1,58 @@
1
+ // src/lazy/index.ts
2
+ function lazy(source, token) {
3
+ let instance;
4
+ let resolved = false;
5
+ const ensureResolved = () => {
6
+ if (!resolved) {
7
+ instance = source.resolve(token);
8
+ resolved = true;
9
+ }
10
+ return instance;
11
+ };
12
+ const proxyTarget = Object.create(null);
13
+ return new Proxy(proxyTarget, {
14
+ defineProperty(_, prop, desc) {
15
+ return Reflect.defineProperty(ensureResolved(), prop, desc);
16
+ },
17
+ deleteProperty(_, prop) {
18
+ return Reflect.deleteProperty(ensureResolved(), prop);
19
+ },
20
+ get(_, prop) {
21
+ const target = ensureResolved();
22
+ const value = Reflect.get(target, prop, target);
23
+ if (typeof value === "function") {
24
+ return value.bind(target);
25
+ }
26
+ return value;
27
+ },
28
+ getOwnPropertyDescriptor(_, prop) {
29
+ return Reflect.getOwnPropertyDescriptor(ensureResolved(), prop);
30
+ },
31
+ getPrototypeOf() {
32
+ return Reflect.getPrototypeOf(ensureResolved());
33
+ },
34
+ has(_, prop) {
35
+ return Reflect.has(ensureResolved(), prop);
36
+ },
37
+ isExtensible() {
38
+ return Reflect.isExtensible(ensureResolved());
39
+ },
40
+ ownKeys() {
41
+ return Reflect.ownKeys(ensureResolved());
42
+ },
43
+ preventExtensions() {
44
+ Reflect.preventExtensions(ensureResolved());
45
+ Reflect.preventExtensions(proxyTarget);
46
+ return true;
47
+ },
48
+ set(_, prop, value) {
49
+ return Reflect.set(ensureResolved(), prop, value, ensureResolved());
50
+ },
51
+ setPrototypeOf(_, proto) {
52
+ return Reflect.setPrototypeOf(ensureResolved(), proto);
53
+ }
54
+ });
55
+ }
56
+ export {
57
+ lazy
58
+ };
@@ -30,6 +30,31 @@ export interface Resolver<T, Sync extends AbstractConstructor = AbstractConstruc
30
30
  tryResolve<K extends keyof T>(token: K): T[K] | undefined;
31
31
  tryResolve<V>(token: AbstractConstructor<V>): V | Promise<V> | undefined;
32
32
  tryResolve(token: PropertyKey): unknown;
33
+ /**
34
+ * Resolve all instances for the given token.
35
+ *
36
+ * Returns an array of instances from all registrations for the token.
37
+ *
38
+ * @param token A registered token
39
+ * @returns An array of instances associated with the token
40
+ */
41
+ resolveAll<V>(token: AbstractConstructor<V> & Async): Promise<V>[];
42
+ resolveAll<V>(token: AbstractConstructor<V> & Sync): V[];
43
+ resolveAll<K extends keyof T>(token: K): T[K][];
44
+ /**
45
+ * Try to resolve all instances for the given token.
46
+ *
47
+ * Returns `undefined` instead of throwing when the token is not registered.
48
+ * Other errors (circular dependency, disposed container) are still thrown.
49
+ *
50
+ * @param token A token to resolve
51
+ * @returns An array of instances associated with the token, or `undefined` if not registered
52
+ */
53
+ tryResolveAll<V>(token: AbstractConstructor<V> & Async): Promise<V>[] | undefined;
54
+ tryResolveAll<V>(token: AbstractConstructor<V> & Sync): V[] | undefined;
55
+ tryResolveAll<K extends keyof T>(token: K): T[K][] | undefined;
56
+ tryResolveAll<V>(token: AbstractConstructor<V>): (V | Promise<V>)[] | undefined;
57
+ tryResolveAll(token: PropertyKey): unknown;
33
58
  }
34
59
  /**
35
60
  * Lifetime of a registration.
@@ -76,29 +76,30 @@ function createScope(source) {
76
76
  if (internals.isDisposed()) {
77
77
  throw new ContainerError("Cannot create a scope from a disposed container.");
78
78
  }
79
- return new Scope(internals.registrations, internals.instances);
79
+ return new Scope(internals.registrations, internals.singletonCache);
80
80
  }
81
81
 
82
82
  class Scope {
83
83
  registrations;
84
- singletonInstances;
85
- scopedInstances;
84
+ singletonCache;
85
+ scopedCache;
86
86
  resolvingTokens;
87
+ singletonDepth = 0;
87
88
  disposed = false;
88
89
  [INTERNALS];
89
- constructor(registrations, singletonInstances) {
90
+ constructor(registrations, singletonCache) {
90
91
  this.registrations = registrations;
91
- this.singletonInstances = singletonInstances;
92
- this.scopedInstances = new Map;
92
+ this.singletonCache = singletonCache;
93
+ this.scopedCache = new Map;
93
94
  this.resolvingTokens = new Set;
94
95
  this[INTERNALS] = {
95
- instances: this.singletonInstances,
96
96
  isDisposed: () => this.disposed,
97
97
  markDisposed: () => {
98
98
  this.disposed = true;
99
99
  },
100
- ownInstances: this.scopedInstances,
101
- registrations: this.registrations
100
+ ownCache: this.scopedCache,
101
+ registrations: this.registrations,
102
+ singletonCache: this.singletonCache
102
103
  };
103
104
  }
104
105
  resolve(token) {
@@ -107,37 +108,103 @@ class Scope {
107
108
  tryResolve(token) {
108
109
  return this.resolveToken(token, false);
109
110
  }
111
+ resolveAll(token) {
112
+ return this.resolveAllTokens(token, true);
113
+ }
114
+ tryResolveAll(token) {
115
+ return this.resolveAllTokens(token, false);
116
+ }
110
117
  resolveToken(token, required) {
111
118
  if (this.disposed) {
112
119
  throw new ContainerError("Cannot resolve from a disposed scope.");
113
120
  }
114
- const singletonCached = this.singletonInstances.get(token);
121
+ const registrations = this.registrations.get(token);
122
+ if (registrations === undefined || registrations.length === 0) {
123
+ if (required) {
124
+ throw new ContainerError(`Token "${tokenToString(token)}" is not registered.`);
125
+ }
126
+ return;
127
+ }
128
+ const registration = registrations[registrations.length - 1];
129
+ const singletonCached = this.singletonCache.get(registration);
115
130
  if (singletonCached !== undefined) {
116
131
  return singletonCached;
117
132
  }
118
- const scopedCached = this.scopedInstances.get(token);
133
+ if (registration.lifetime === "scoped" && this.singletonDepth > 0) {
134
+ throw new ContainerError(`Captive dependency detected: scoped token "${tokenToString(token)}" cannot be resolved inside a singleton factory. Scoped instances must not be captured by singletons.`);
135
+ }
136
+ const scopedCached = this.scopedCache.get(registration);
119
137
  if (scopedCached !== undefined) {
120
138
  return scopedCached;
121
139
  }
122
- const registration = this.registrations.get(token);
123
- if (registration === undefined) {
124
- if (required) {
125
- throw new ContainerError(`Token "${tokenToString(token)}" is not registered.`);
126
- }
127
- return;
128
- }
129
140
  if (this.resolvingTokens.has(token)) {
130
141
  throw new ContainerError(`Circular dependency detected: ${buildCircularPath(this.resolvingTokens, token)}`);
131
142
  }
132
143
  this.resolvingTokens.add(token);
144
+ if (registration.lifetime === "singleton") {
145
+ this.singletonDepth++;
146
+ }
133
147
  try {
134
148
  const instance = registration.factory(this);
135
149
  if (registration.lifetime === "singleton") {
136
- this.singletonInstances.set(token, instance);
150
+ this.singletonCache.set(registration, instance);
137
151
  } else if (registration.lifetime === "scoped") {
138
- this.scopedInstances.set(token, instance);
152
+ this.scopedCache.set(registration, instance);
139
153
  }
140
154
  return instance;
155
+ } finally {
156
+ if (registration.lifetime === "singleton") {
157
+ this.singletonDepth--;
158
+ }
159
+ this.resolvingTokens.delete(token);
160
+ }
161
+ }
162
+ resolveAllTokens(token, required) {
163
+ if (this.disposed) {
164
+ throw new ContainerError("Cannot resolve from a disposed scope.");
165
+ }
166
+ const registrations = this.registrations.get(token);
167
+ if (registrations === undefined || registrations.length === 0) {
168
+ if (required) {
169
+ throw new ContainerError(`Token "${tokenToString(token)}" is not registered.`);
170
+ }
171
+ return;
172
+ }
173
+ if (this.resolvingTokens.has(token)) {
174
+ throw new ContainerError(`Circular dependency detected: ${buildCircularPath(this.resolvingTokens, token)}`);
175
+ }
176
+ this.resolvingTokens.add(token);
177
+ try {
178
+ return registrations.map((registration) => {
179
+ const reg = registration;
180
+ const singletonCached = this.singletonCache.get(registration);
181
+ if (singletonCached !== undefined) {
182
+ return singletonCached;
183
+ }
184
+ if (reg.lifetime === "scoped" && this.singletonDepth > 0) {
185
+ throw new ContainerError(`Captive dependency detected: scoped token "${tokenToString(token)}" cannot be resolved inside a singleton factory. Scoped instances must not be captured by singletons.`);
186
+ }
187
+ const scopedCached = this.scopedCache.get(registration);
188
+ if (scopedCached !== undefined) {
189
+ return scopedCached;
190
+ }
191
+ if (reg.lifetime === "singleton") {
192
+ this.singletonDepth++;
193
+ }
194
+ try {
195
+ const instance = reg.factory(this);
196
+ if (reg.lifetime === "singleton") {
197
+ this.singletonCache.set(registration, instance);
198
+ } else if (reg.lifetime === "scoped") {
199
+ this.scopedCache.set(registration, instance);
200
+ }
201
+ return instance;
202
+ } finally {
203
+ if (reg.lifetime === "singleton") {
204
+ this.singletonDepth--;
205
+ }
206
+ }
207
+ });
141
208
  } finally {
142
209
  this.resolvingTokens.delete(token);
143
210
  }
@@ -32,9 +32,10 @@ export declare function createScope<T, Sync extends AbstractConstructor, Async e
32
32
  */
33
33
  export declare class Scope<T = Record<never, never>, Sync extends AbstractConstructor = never, Async extends AbstractConstructor = never, ScopedT = Record<never, never>, ScopedSync extends AbstractConstructor = never, ScopedAsync extends AbstractConstructor = never> {
34
34
  private readonly registrations;
35
- private readonly singletonInstances;
36
- private readonly scopedInstances;
35
+ private readonly singletonCache;
36
+ private readonly scopedCache;
37
37
  private readonly resolvingTokens;
38
+ private singletonDepth;
38
39
  private disposed;
39
40
  /**
40
41
  * Internal state accessor for extension modules (scope, disposable).
@@ -42,7 +43,7 @@ export declare class Scope<T = Record<never, never>, Sync extends AbstractConstr
42
43
  * @internal
43
44
  */
44
45
  readonly [INTERNALS]: ContainerInternals;
45
- constructor(registrations: Map<unknown, Registration>, singletonInstances: Map<unknown, unknown>);
46
+ constructor(registrations: Map<unknown, Registration[]>, singletonCache: Map<Registration, unknown>);
46
47
  /**
47
48
  * Resolve an instance for the given token.
48
49
  *
@@ -71,12 +72,49 @@ export declare class Scope<T = Record<never, never>, Sync extends AbstractConstr
71
72
  tryResolve<K extends keyof (T & ScopedT)>(token: K): (T & ScopedT)[K] | undefined;
72
73
  tryResolve<V>(token: AbstractConstructor<V>): V | Promise<V> | undefined;
73
74
  tryResolve(token: PropertyKey): unknown;
75
+ /**
76
+ * Resolve all instances for the given token.
77
+ *
78
+ * Returns an array of instances from all registered factories for the token,
79
+ * in registration order.
80
+ *
81
+ * @param token A registered token
82
+ * @returns An array of instances associated with the token
83
+ * @throws ContainerError if the token is not registered
84
+ */
85
+ resolveAll<V>(token: AbstractConstructor<V> & (Async | ScopedAsync)): Promise<V>[];
86
+ resolveAll<V>(token: AbstractConstructor<V> & (Sync | ScopedSync)): V[];
87
+ resolveAll<K extends keyof (T & ScopedT)>(token: K): (T & ScopedT)[K][];
88
+ /**
89
+ * Try to resolve all instances for the given token.
90
+ *
91
+ * Returns `undefined` instead of throwing when the token is not registered.
92
+ * Other errors (circular dependency, disposed scope) are still thrown.
93
+ *
94
+ * @param token A token to resolve
95
+ * @returns An array of instances associated with the token, or `undefined` if not registered
96
+ */
97
+ tryResolveAll<V>(token: AbstractConstructor<V> & (Async | ScopedAsync)): Promise<V>[] | undefined;
98
+ tryResolveAll<V>(token: AbstractConstructor<V> & (Sync | ScopedSync)): V[] | undefined;
99
+ tryResolveAll<K extends keyof (T & ScopedT)>(token: K): (T & ScopedT)[K][] | undefined;
100
+ tryResolveAll<V>(token: AbstractConstructor<V>): (V | Promise<V>)[] | undefined;
101
+ tryResolveAll(token: PropertyKey): unknown;
74
102
  /**
75
103
  * Internal resolution logic shared by resolve and tryResolve.
104
+ * Resolves the last registered factory for the token.
76
105
  *
77
106
  * @param token Token to resolve
78
107
  * @param required If true, throws when the token is not registered. If false, returns undefined.
79
108
  * @returns The resolved instance, or undefined if not registered and required is false
80
109
  */
81
110
  private resolveToken;
111
+ /**
112
+ * Internal resolution logic shared by resolveAll and tryResolveAll.
113
+ * Resolves all registered factories for the token.
114
+ *
115
+ * @param token Token to resolve
116
+ * @param required If true, throws when the token is not registered. If false, returns undefined.
117
+ * @returns An array of resolved instances, or undefined if not registered and required is false
118
+ */
119
+ private resolveAllTokens;
82
120
  }
@@ -13,29 +13,30 @@ function createScope(source) {
13
13
  if (internals.isDisposed()) {
14
14
  throw new ContainerError("Cannot create a scope from a disposed container.");
15
15
  }
16
- return new Scope(internals.registrations, internals.instances);
16
+ return new Scope(internals.registrations, internals.singletonCache);
17
17
  }
18
18
 
19
19
  class Scope {
20
20
  registrations;
21
- singletonInstances;
22
- scopedInstances;
21
+ singletonCache;
22
+ scopedCache;
23
23
  resolvingTokens;
24
+ singletonDepth = 0;
24
25
  disposed = false;
25
26
  [INTERNALS];
26
- constructor(registrations, singletonInstances) {
27
+ constructor(registrations, singletonCache) {
27
28
  this.registrations = registrations;
28
- this.singletonInstances = singletonInstances;
29
- this.scopedInstances = new Map;
29
+ this.singletonCache = singletonCache;
30
+ this.scopedCache = new Map;
30
31
  this.resolvingTokens = new Set;
31
32
  this[INTERNALS] = {
32
- instances: this.singletonInstances,
33
33
  isDisposed: () => this.disposed,
34
34
  markDisposed: () => {
35
35
  this.disposed = true;
36
36
  },
37
- ownInstances: this.scopedInstances,
38
- registrations: this.registrations
37
+ ownCache: this.scopedCache,
38
+ registrations: this.registrations,
39
+ singletonCache: this.singletonCache
39
40
  };
40
41
  }
41
42
  resolve(token) {
@@ -44,37 +45,103 @@ class Scope {
44
45
  tryResolve(token) {
45
46
  return this.resolveToken(token, false);
46
47
  }
48
+ resolveAll(token) {
49
+ return this.resolveAllTokens(token, true);
50
+ }
51
+ tryResolveAll(token) {
52
+ return this.resolveAllTokens(token, false);
53
+ }
47
54
  resolveToken(token, required) {
48
55
  if (this.disposed) {
49
56
  throw new ContainerError("Cannot resolve from a disposed scope.");
50
57
  }
51
- const singletonCached = this.singletonInstances.get(token);
58
+ const registrations = this.registrations.get(token);
59
+ if (registrations === undefined || registrations.length === 0) {
60
+ if (required) {
61
+ throw new ContainerError(`Token "${tokenToString(token)}" is not registered.`);
62
+ }
63
+ return;
64
+ }
65
+ const registration = registrations[registrations.length - 1];
66
+ const singletonCached = this.singletonCache.get(registration);
52
67
  if (singletonCached !== undefined) {
53
68
  return singletonCached;
54
69
  }
55
- const scopedCached = this.scopedInstances.get(token);
70
+ if (registration.lifetime === "scoped" && this.singletonDepth > 0) {
71
+ throw new ContainerError(`Captive dependency detected: scoped token "${tokenToString(token)}" cannot be resolved inside a singleton factory. Scoped instances must not be captured by singletons.`);
72
+ }
73
+ const scopedCached = this.scopedCache.get(registration);
56
74
  if (scopedCached !== undefined) {
57
75
  return scopedCached;
58
76
  }
59
- const registration = this.registrations.get(token);
60
- if (registration === undefined) {
61
- if (required) {
62
- throw new ContainerError(`Token "${tokenToString(token)}" is not registered.`);
63
- }
64
- return;
65
- }
66
77
  if (this.resolvingTokens.has(token)) {
67
78
  throw new ContainerError(`Circular dependency detected: ${buildCircularPath(this.resolvingTokens, token)}`);
68
79
  }
69
80
  this.resolvingTokens.add(token);
81
+ if (registration.lifetime === "singleton") {
82
+ this.singletonDepth++;
83
+ }
70
84
  try {
71
85
  const instance = registration.factory(this);
72
86
  if (registration.lifetime === "singleton") {
73
- this.singletonInstances.set(token, instance);
87
+ this.singletonCache.set(registration, instance);
74
88
  } else if (registration.lifetime === "scoped") {
75
- this.scopedInstances.set(token, instance);
89
+ this.scopedCache.set(registration, instance);
76
90
  }
77
91
  return instance;
92
+ } finally {
93
+ if (registration.lifetime === "singleton") {
94
+ this.singletonDepth--;
95
+ }
96
+ this.resolvingTokens.delete(token);
97
+ }
98
+ }
99
+ resolveAllTokens(token, required) {
100
+ if (this.disposed) {
101
+ throw new ContainerError("Cannot resolve from a disposed scope.");
102
+ }
103
+ const registrations = this.registrations.get(token);
104
+ if (registrations === undefined || registrations.length === 0) {
105
+ if (required) {
106
+ throw new ContainerError(`Token "${tokenToString(token)}" is not registered.`);
107
+ }
108
+ return;
109
+ }
110
+ if (this.resolvingTokens.has(token)) {
111
+ throw new ContainerError(`Circular dependency detected: ${buildCircularPath(this.resolvingTokens, token)}`);
112
+ }
113
+ this.resolvingTokens.add(token);
114
+ try {
115
+ return registrations.map((registration) => {
116
+ const reg = registration;
117
+ const singletonCached = this.singletonCache.get(registration);
118
+ if (singletonCached !== undefined) {
119
+ return singletonCached;
120
+ }
121
+ if (reg.lifetime === "scoped" && this.singletonDepth > 0) {
122
+ throw new ContainerError(`Captive dependency detected: scoped token "${tokenToString(token)}" cannot be resolved inside a singleton factory. Scoped instances must not be captured by singletons.`);
123
+ }
124
+ const scopedCached = this.scopedCache.get(registration);
125
+ if (scopedCached !== undefined) {
126
+ return scopedCached;
127
+ }
128
+ if (reg.lifetime === "singleton") {
129
+ this.singletonDepth++;
130
+ }
131
+ try {
132
+ const instance = reg.factory(this);
133
+ if (reg.lifetime === "singleton") {
134
+ this.singletonCache.set(registration, instance);
135
+ } else if (reg.lifetime === "scoped") {
136
+ this.scopedCache.set(registration, instance);
137
+ }
138
+ return instance;
139
+ } finally {
140
+ if (reg.lifetime === "singleton") {
141
+ this.singletonDepth--;
142
+ }
143
+ }
144
+ });
78
145
  } finally {
79
146
  this.resolvingTokens.delete(token);
80
147
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "katagami",
3
- "version": "2.1.0",
3
+ "version": "2.3.0",
4
4
  "description": "Lightweight DI container for TypeScript and JavaScript — full type inference, no decorators, no reflect-metadata, hybrid class & PropertyKey tokens.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -20,6 +20,11 @@
20
20
  "types": "./dist/disposable/index.d.ts",
21
21
  "import": "./dist/disposable/index.js",
22
22
  "require": "./dist/disposable/index.cjs"
23
+ },
24
+ "./lazy": {
25
+ "types": "./dist/lazy/index.d.ts",
26
+ "import": "./dist/lazy/index.js",
27
+ "require": "./dist/lazy/index.cjs"
23
28
  }
24
29
  },
25
30
  "main": "./dist/index.cjs",
@@ -45,14 +50,13 @@
45
50
  "clean": "rm -rf dist",
46
51
  "build": "bun run clean && bun run build:types && bun run build:esm && bun run build:cjs",
47
52
  "build:types": "tsc -p tsconfig.build.json",
48
- "build:esm": "bun build ./src/index.ts ./src/scope/index.ts ./src/disposable/index.ts --outdir dist --format esm --splitting",
49
- "build:cjs": "bun build ./src/index.ts --outfile dist/index.cjs --format cjs && bun build ./src/scope/index.ts --outfile dist/scope/index.cjs --format cjs && bun build ./src/disposable/index.ts --outfile dist/disposable/index.cjs --format cjs",
50
- "test": "bun test --coverage --dots",
53
+ "build:esm": "bun build ./src/index.ts ./src/scope/index.ts ./src/disposable/index.ts ./src/lazy/index.ts --outdir dist --format esm --splitting",
54
+ "build:cjs": "bun build ./src/index.ts --outfile dist/index.cjs --format cjs && bun build ./src/scope/index.ts --outfile dist/scope/index.cjs --format cjs && bun build ./src/disposable/index.ts --outfile dist/disposable/index.cjs --format cjs && bun build ./src/lazy/index.ts --outfile dist/lazy/index.cjs --format cjs",
51
55
  "prepublishOnly": "bun run build",
52
56
  "check": "bun run format",
53
57
  "lint": "biome lint .",
54
58
  "format": "biome check --write .",
55
- "verify": "bun run build:types && bun run check && bun run test"
59
+ "verify": "bun run build:types && bun run check && bun test"
56
60
  },
57
61
  "dependencies": {
58
62
  "@types/bun": "^1.3.8"