katagami 2.2.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 |
@@ -65,6 +65,29 @@ userService.greet('world');
65
65
 
66
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.
67
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
+
68
91
  ### No decorators, no reflect-metadata
69
92
 
70
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.
@@ -468,6 +491,24 @@ const container = createContainer()
468
491
  });
469
492
  ```
470
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
+
471
512
  ### Optional Resolution (tryResolve)
472
513
 
473
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`:
@@ -84,6 +84,7 @@ class Scope {
84
84
  singletonCache;
85
85
  scopedCache;
86
86
  resolvingTokens;
87
+ singletonDepth = 0;
87
88
  disposed = false;
88
89
  [INTERNALS];
89
90
  constructor(registrations, singletonCache) {
@@ -129,6 +130,9 @@ class Scope {
129
130
  if (singletonCached !== undefined) {
130
131
  return singletonCached;
131
132
  }
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
+ }
132
136
  const scopedCached = this.scopedCache.get(registration);
133
137
  if (scopedCached !== undefined) {
134
138
  return scopedCached;
@@ -137,6 +141,9 @@ class Scope {
137
141
  throw new ContainerError(`Circular dependency detected: ${buildCircularPath(this.resolvingTokens, token)}`);
138
142
  }
139
143
  this.resolvingTokens.add(token);
144
+ if (registration.lifetime === "singleton") {
145
+ this.singletonDepth++;
146
+ }
140
147
  try {
141
148
  const instance = registration.factory(this);
142
149
  if (registration.lifetime === "singleton") {
@@ -146,6 +153,9 @@ class Scope {
146
153
  }
147
154
  return instance;
148
155
  } finally {
156
+ if (registration.lifetime === "singleton") {
157
+ this.singletonDepth--;
158
+ }
149
159
  this.resolvingTokens.delete(token);
150
160
  }
151
161
  }
@@ -171,17 +181,29 @@ class Scope {
171
181
  if (singletonCached !== undefined) {
172
182
  return singletonCached;
173
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
+ }
174
187
  const scopedCached = this.scopedCache.get(registration);
175
188
  if (scopedCached !== undefined) {
176
189
  return scopedCached;
177
190
  }
178
- const instance = reg.factory(this);
179
191
  if (reg.lifetime === "singleton") {
180
- this.singletonCache.set(registration, instance);
181
- } else if (reg.lifetime === "scoped") {
182
- this.scopedCache.set(registration, instance);
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
+ }
183
206
  }
184
- return instance;
185
207
  });
186
208
  } finally {
187
209
  this.resolvingTokens.delete(token);
@@ -35,6 +35,7 @@ export declare class Scope<T = Record<never, never>, Sync extends AbstractConstr
35
35
  private readonly singletonCache;
36
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).
@@ -21,6 +21,7 @@ class Scope {
21
21
  singletonCache;
22
22
  scopedCache;
23
23
  resolvingTokens;
24
+ singletonDepth = 0;
24
25
  disposed = false;
25
26
  [INTERNALS];
26
27
  constructor(registrations, singletonCache) {
@@ -66,6 +67,9 @@ class Scope {
66
67
  if (singletonCached !== undefined) {
67
68
  return singletonCached;
68
69
  }
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
+ }
69
73
  const scopedCached = this.scopedCache.get(registration);
70
74
  if (scopedCached !== undefined) {
71
75
  return scopedCached;
@@ -74,6 +78,9 @@ class Scope {
74
78
  throw new ContainerError(`Circular dependency detected: ${buildCircularPath(this.resolvingTokens, token)}`);
75
79
  }
76
80
  this.resolvingTokens.add(token);
81
+ if (registration.lifetime === "singleton") {
82
+ this.singletonDepth++;
83
+ }
77
84
  try {
78
85
  const instance = registration.factory(this);
79
86
  if (registration.lifetime === "singleton") {
@@ -83,6 +90,9 @@ class Scope {
83
90
  }
84
91
  return instance;
85
92
  } finally {
93
+ if (registration.lifetime === "singleton") {
94
+ this.singletonDepth--;
95
+ }
86
96
  this.resolvingTokens.delete(token);
87
97
  }
88
98
  }
@@ -108,17 +118,29 @@ class Scope {
108
118
  if (singletonCached !== undefined) {
109
119
  return singletonCached;
110
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
+ }
111
124
  const scopedCached = this.scopedCache.get(registration);
112
125
  if (scopedCached !== undefined) {
113
126
  return scopedCached;
114
127
  }
115
- const instance = reg.factory(this);
116
128
  if (reg.lifetime === "singleton") {
117
- this.singletonCache.set(registration, instance);
118
- } else if (reg.lifetime === "scoped") {
119
- this.scopedCache.set(registration, instance);
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
+ }
120
143
  }
121
- return instance;
122
144
  });
123
145
  } finally {
124
146
  this.resolvingTokens.delete(token);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "katagami",
3
- "version": "2.2.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",