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 +42 -1
- package/dist/scope/index.cjs +27 -5
- package/dist/scope/index.d.ts +1 -0
- package/dist/scope/index.js +27 -5
- package/package.json +1 -1
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`:
|
package/dist/scope/index.cjs
CHANGED
|
@@ -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.
|
|
181
|
-
}
|
|
182
|
-
|
|
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);
|
package/dist/scope/index.d.ts
CHANGED
|
@@ -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).
|
package/dist/scope/index.js
CHANGED
|
@@ -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.
|
|
118
|
-
}
|
|
119
|
-
|
|
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.
|
|
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",
|