katagami 1.1.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 ADDED
@@ -0,0 +1,438 @@
1
+ [English](./README.md) | [日本語](./README.ja.md) | [한국어](./README.ko.md) | [繁體中文](./README.zh-TW.md) | [简体中文](./README.zh-CN.md) | [Español](./README.es.md) | [Deutsch](./README.de.md) | [Français](./README.fr.md)
2
+
3
+ # Katagami
4
+
5
+ Lightweight TypeScript DI container with full type inference.
6
+
7
+ [![npm version](https://img.shields.io/npm/v/katagami)](https://www.npmjs.com/package/katagami)
8
+ [![license](https://img.shields.io/npm/l/katagami)](https://github.com/hiroiku/katagami/blob/master/LICENSE)
9
+ [![bundle size](https://img.shields.io/bundlephobia/minzip/katagami)](https://bundlephobia.com/package/katagami)
10
+
11
+ > The name comes from 型紙 _(katagami)_ — precision stencil paper used in traditional Japanese dyeing to transfer exact patterns onto fabric. Multiple stencils are layered to compose intricate designs, just as types accumulate through each method-chain call. A stencil needs only paper and a brush, no elaborate machinery — likewise, Katagami requires no decorators or metadata mechanisms and works with any build tool out of the box. And like stencils that work across different fabrics and techniques, Katagami adapts across TypeScript and JavaScript, class tokens and PropertyKey tokens — a hybrid approach to strict, composable DI.
12
+
13
+ ## Features
14
+
15
+ | Feature | Description |
16
+ | ----------------------------- | -------------------------------------------------------------------------------------------- |
17
+ | Full type inference | Types accumulate through method chaining; unregistered tokens are compile-time errors |
18
+ | Three lifetimes | Singleton, Transient, and Scoped with child containers |
19
+ | Async factories | Promise-returning factories are automatically tracked by the type system |
20
+ | Circular dependency detection | Clear error messages with the full cycle path |
21
+ | Disposable support | TC39 Explicit Resource Management (`Symbol.dispose` / `Symbol.asyncDispose` / `await using`) |
22
+ | Captive dependency prevention | Singleton/Transient factories cannot access scoped tokens; caught at compile time |
23
+ | Optional resolution | `tryResolve` returns `undefined` for unregistered tokens instead of throwing |
24
+ | Hybrid token strategy | Class tokens for strict type safety, PropertyKey tokens for flexibility |
25
+ | Interface type map | Pass an interface to `createContainer<T>()` for order-independent registration |
26
+ | Zero dependencies | No decorators, no reflect-metadata, no polyfills |
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ npm install katagami
32
+ ```
33
+
34
+ ## Quick Start
35
+
36
+ ```ts
37
+ import { createContainer } from 'katagami';
38
+
39
+ class Logger {
40
+ log(msg: string) {
41
+ console.log(msg);
42
+ }
43
+ }
44
+
45
+ class UserService {
46
+ constructor(private logger: Logger) {}
47
+ greet(name: string) {
48
+ this.logger.log(`Hello, ${name}`);
49
+ }
50
+ }
51
+
52
+ const container = createContainer()
53
+ .registerSingleton(Logger, () => new Logger())
54
+ .registerSingleton(UserService, r => new UserService(r.resolve(Logger)));
55
+
56
+ const userService = container.resolve(UserService);
57
+ // ^? UserService (fully inferred)
58
+ userService.greet('world');
59
+ ```
60
+
61
+ ## Why Katagami
62
+
63
+ 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.
64
+
65
+ ### No decorators, no reflect-metadata
66
+
67
+ 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.
68
+
69
+ ### Full type inference from class tokens
70
+
71
+ String-token DI forces you to maintain manual token-to-type mappings. Parameter-name matching breaks under minification. Katagami uses classes directly as tokens, so `resolve` automatically infers the correct return type — synchronous or `Promise` — with no extra annotations.
72
+
73
+ ### Method-chain type accumulation
74
+
75
+ Types accumulate with each `register` call. Inside a factory, the resolver only accepts tokens that have already been registered at that point in the chain. Resolving an unregistered token is a compile-time error, not a runtime surprise.
76
+
77
+ ### Hybrid token strategy
78
+
79
+ Class tokens give you strict, order-dependent type safety through method chaining. But sometimes you want to define a set of services upfront and register them in any order. Pass an interface to `createContainer<T>()` and use PropertyKey tokens — the type map is fixed at creation time, so registration order does not matter.
80
+
81
+ ### Zero dependencies
82
+
83
+ No runtime dependencies, no polyfills. No need to add reflect-metadata (~50 KB unminified) to your bundle.
84
+
85
+ ## Guide
86
+
87
+ ### Singleton & Transient
88
+
89
+ Singleton creates the instance on the first `resolve` and caches it. Transient creates a new instance every time.
90
+
91
+ ```ts
92
+ import { createContainer } from 'katagami';
93
+
94
+ class Database {
95
+ constructor(public id = Math.random()) {}
96
+ }
97
+
98
+ class RequestHandler {
99
+ constructor(public id = Math.random()) {}
100
+ }
101
+
102
+ const container = createContainer()
103
+ .registerSingleton(Database, () => new Database())
104
+ .registerTransient(RequestHandler, () => new RequestHandler());
105
+
106
+ // Singleton — same instance every time
107
+ container.resolve(Database) === container.resolve(Database); // true
108
+
109
+ // Transient — new instance every time
110
+ container.resolve(RequestHandler) === container.resolve(RequestHandler); // false
111
+ ```
112
+
113
+ ### Scoped Lifetime & Child Containers
114
+
115
+ Scoped registrations behave like singletons within a scope but produce a fresh instance in each new scope. Use `createScope()` to create a child container. Scoped tokens cannot be resolved from the root container.
116
+
117
+ ```ts
118
+ import { createContainer } from 'katagami';
119
+
120
+ class DbPool {
121
+ constructor(public name = 'main') {}
122
+ }
123
+
124
+ class RequestContext {
125
+ constructor(public id = Math.random()) {}
126
+ }
127
+
128
+ const root = createContainer()
129
+ .registerSingleton(DbPool, () => new DbPool())
130
+ .registerScoped(RequestContext, () => new RequestContext());
131
+
132
+ // Create a scope for each request
133
+ const scope1 = root.createScope();
134
+ const scope2 = root.createScope();
135
+
136
+ // Scoped — same within a scope, different across scopes
137
+ scope1.resolve(RequestContext) === scope1.resolve(RequestContext); // true
138
+ scope1.resolve(RequestContext) === scope2.resolve(RequestContext); // false
139
+
140
+ // Singleton — shared across all scopes
141
+ scope1.resolve(DbPool) === scope2.resolve(DbPool); // true
142
+ ```
143
+
144
+ Scopes can also be nested. Each nested scope has its own scoped instance cache while sharing singletons with its parent:
145
+
146
+ ```ts
147
+ const parentScope = root.createScope();
148
+ const childScope = parentScope.createScope();
149
+
150
+ // Each nested scope gets its own scoped instances
151
+ parentScope.resolve(RequestContext) === childScope.resolve(RequestContext); // false
152
+
153
+ // Singletons are still shared
154
+ parentScope.resolve(DbPool) === childScope.resolve(DbPool); // true
155
+ ```
156
+
157
+ ### Async Factories
158
+
159
+ Factories that return a `Promise` are automatically tracked by the type system. When you `resolve` an async token, the return type is `Promise<V>` instead of `V`:
160
+
161
+ ```ts
162
+ import { createContainer } from 'katagami';
163
+
164
+ class Database {
165
+ constructor(public connected: boolean) {}
166
+ }
167
+
168
+ class Logger {
169
+ log(msg: string) {
170
+ console.log(msg);
171
+ }
172
+ }
173
+
174
+ const container = createContainer()
175
+ .registerSingleton(Logger, () => new Logger())
176
+ .registerSingleton(Database, async () => {
177
+ await new Promise(r => setTimeout(r, 100)); // simulate async init
178
+ return new Database(true);
179
+ });
180
+
181
+ const logger = container.resolve(Logger);
182
+ // ^? Logger
183
+
184
+ const db = await container.resolve(Database);
185
+ // ^? Promise<Database> (awaited → Database)
186
+ db.connected; // true
187
+ ```
188
+
189
+ Async factories can depend on both sync and async registrations:
190
+
191
+ ```ts
192
+ const container = createContainer()
193
+ .registerSingleton(Logger, () => new Logger())
194
+ .registerSingleton(Database, async r => {
195
+ const logger = r.resolve(Logger); // sync → Logger
196
+ logger.log('Connecting...');
197
+ return new Database(true);
198
+ });
199
+ ```
200
+
201
+ ### Circular Dependency Detection
202
+
203
+ Katagami tracks which tokens are currently being resolved. If a circular dependency is found, a `ContainerError` is thrown with a clear message showing the full cycle path:
204
+
205
+ ```ts
206
+ import { createContainer } from 'katagami';
207
+
208
+ class ServiceA {
209
+ constructor(public b: ServiceB) {}
210
+ }
211
+
212
+ class ServiceB {
213
+ constructor(public a: ServiceA) {}
214
+ }
215
+
216
+ const container = createContainer()
217
+ .registerSingleton(ServiceA, r => new ServiceA(r.resolve(ServiceB)))
218
+ .registerSingleton(ServiceB, r => new ServiceB(r.resolve(ServiceA)));
219
+
220
+ container.resolve(ServiceA);
221
+ // ContainerError: Circular dependency detected: ServiceA -> ServiceB -> ServiceA
222
+ ```
223
+
224
+ Indirect cycles are also detected:
225
+
226
+ ```
227
+ ContainerError: Circular dependency detected: ServiceX -> ServiceY -> ServiceZ -> ServiceX
228
+ ```
229
+
230
+ ### Disposable Support
231
+
232
+ Both `Container` and `Scope` implement `AsyncDisposable`. When disposed, managed instances are iterated in reverse creation order (LIFO) and their `[Symbol.asyncDispose]()` or `[Symbol.dispose]()` methods are called automatically.
233
+
234
+ ```ts
235
+ import { createContainer } from 'katagami';
236
+
237
+ class Connection {
238
+ async [Symbol.asyncDispose]() {
239
+ console.log('Connection closed');
240
+ }
241
+ }
242
+
243
+ // Manual disposal
244
+ const container = createContainer().registerSingleton(Connection, () => new Connection());
245
+
246
+ container.resolve(Connection);
247
+ await container[Symbol.asyncDispose]();
248
+ // => "Connection closed"
249
+ ```
250
+
251
+ With `await using`, scopes are automatically disposed at the end of the block:
252
+
253
+ ```ts
254
+ const root = createContainer()
255
+ .registerSingleton(DbPool, () => new DbPool())
256
+ .registerScoped(Connection, () => new Connection());
257
+
258
+ {
259
+ await using scope = root.createScope();
260
+ const conn = scope.resolve(Connection);
261
+ // ... use conn ...
262
+ } // scope is disposed here — Connection is cleaned up, DbPool is not
263
+ ```
264
+
265
+ Scope disposal only affects scoped instances. Singleton instances are owned by the root container and are disposed when the container itself is disposed.
266
+
267
+ ### Interface Type Map
268
+
269
+ When you pass an interface to `createContainer<T>()`, PropertyKey tokens are typed from the interface rather than accumulated through chaining. This means you can register and resolve tokens in any order:
270
+
271
+ ```ts
272
+ import { createContainer } from 'katagami';
273
+
274
+ class Logger {
275
+ log(msg: string) {
276
+ console.log(msg);
277
+ }
278
+ }
279
+
280
+ interface Services {
281
+ logger: Logger;
282
+ greeting: string;
283
+ }
284
+
285
+ const container = createContainer<Services>()
286
+ // 'greeting' can reference 'logger' even though it is registered later
287
+ .registerSingleton('greeting', r => {
288
+ r.resolve('logger').log('Building greeting...');
289
+ return 'Hello!';
290
+ })
291
+ .registerSingleton('logger', () => new Logger());
292
+
293
+ const greeting = container.resolve('greeting');
294
+ // ^? string
295
+ ```
296
+
297
+ ### Hybrid Token Strategy
298
+
299
+ You can mix both approaches — use class tokens for order-dependent type safety and PropertyKey tokens for order-independent flexibility:
300
+
301
+ ```ts
302
+ const container = createContainer<Services>()
303
+ .registerSingleton(Logger, () => new Logger())
304
+ .registerSingleton('logger', () => new Logger())
305
+ .registerSingleton('greeting', r => {
306
+ r.resolve(Logger).log('Building greeting...');
307
+ return 'Hello!';
308
+ });
309
+ ```
310
+
311
+ ### Captive Dependency Prevention
312
+
313
+ A "captive dependency" occurs when a long-lived service (singleton or transient) captures a short-lived service (scoped), keeping it alive beyond its intended scope. Katagami prevents this at compile time — singleton and transient factories only receive a resolver limited to non-scoped tokens:
314
+
315
+ ```ts
316
+ import { createContainer } from 'katagami';
317
+
318
+ class DbPool {}
319
+ class RequestContext {}
320
+
321
+ const container = createContainer()
322
+ .registerScoped(RequestContext, () => new RequestContext())
323
+ // @ts-expect-error — singleton factory cannot resolve scoped token
324
+ .registerSingleton(DbPool, r => new DbPool(r.resolve(RequestContext)));
325
+ ```
326
+
327
+ Scoped factories, on the other hand, can resolve both scoped and non-scoped tokens:
328
+
329
+ ```ts
330
+ const container = createContainer()
331
+ .registerSingleton(DbPool, () => new DbPool())
332
+ .registerScoped(RequestContext, r => {
333
+ r.resolve(DbPool); // OK — scoped factory can resolve singleton tokens
334
+ return new RequestContext();
335
+ });
336
+ ```
337
+
338
+ ### Optional Resolution (tryResolve)
339
+
340
+ 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`:
341
+
342
+ ```ts
343
+ import { createContainer } from 'katagami';
344
+
345
+ class Logger {
346
+ log(msg: string) {
347
+ console.log(msg);
348
+ }
349
+ }
350
+
351
+ class Analytics {
352
+ track(event: string) {
353
+ console.log(`Track: ${event}`);
354
+ }
355
+ }
356
+
357
+ const container = createContainer().registerSingleton(Logger, () => new Logger());
358
+
359
+ // resolve throws for unregistered tokens
360
+ container.resolve(Analytics); // ContainerError: Token "Analytics" is not registered.
361
+
362
+ // tryResolve returns undefined for unregistered tokens
363
+ const analytics = container.tryResolve(Analytics);
364
+ // ^? Analytics | undefined
365
+ if (analytics) {
366
+ analytics.track('event');
367
+ }
368
+ ```
369
+
370
+ `tryResolve` is especially useful for optional dependencies in factories. Unlike `resolve`, it accepts unregistered tokens without compile-time errors:
371
+
372
+ ```ts
373
+ const container = createContainer()
374
+ .registerSingleton(Logger, () => new Logger())
375
+ .registerSingleton('UserService', r => {
376
+ const logger = r.tryResolve(Logger); // Optional dependency
377
+ const analytics = r.tryResolve(Analytics); // No compile error even though Analytics is not registered
378
+
379
+ return {
380
+ greet(name: string) {
381
+ logger?.log(`Hello, ${name}`);
382
+ analytics?.track('user_greeted');
383
+ },
384
+ };
385
+ });
386
+ ```
387
+
388
+ `tryResolve` still throws `ContainerError` for circular dependencies and operations on disposed containers/scopes — only unregistered tokens return `undefined`.
389
+
390
+ ## API
391
+
392
+ ### `createContainer<T, ScopedT>()`
393
+
394
+ Creates a new DI container. Pass an interface as `T` to define the type map for PropertyKey tokens. Pass `ScopedT` to define a separate type map for scoped PropertyKey tokens (order-independent, just like `T`).
395
+
396
+ ### `container.registerSingleton(token, factory)`
397
+
398
+ Registers a factory as a singleton. The instance is created on the first `resolve` and cached thereafter. Returns the container for method chaining.
399
+
400
+ ### `container.registerTransient(token, factory)`
401
+
402
+ Registers a factory as transient. A new instance is created on every `resolve`. Returns the container for method chaining.
403
+
404
+ ### `container.registerScoped(token, factory)`
405
+
406
+ Registers a factory as scoped. Within a scope, the instance is created on the first `resolve` and cached for that scope. Each scope maintains its own cache. Scoped tokens cannot be resolved from the root container. Returns the container for method chaining.
407
+
408
+ ### `container.resolve(token)`
409
+
410
+ Resolves and returns the instance for the given token. Throws `ContainerError` if the token is not registered or if a circular dependency is detected.
411
+
412
+ ### `container.tryResolve(token)` / `scope.tryResolve(token)`
413
+
414
+ 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 containers/scopes.
415
+
416
+ ### `container.createScope()`
417
+
418
+ Creates a new `Scope` (child container). The scope inherits all registrations from the parent. Singleton instances are shared with the parent, while scoped instances are local to the scope.
419
+
420
+ ### `Scope`
421
+
422
+ A scoped child container created by `createScope()`. Provides `resolve(token)`, `tryResolve(token)`, `createScope()` (for nested scopes), and `[Symbol.asyncDispose]()`.
423
+
424
+ ### `container[Symbol.asyncDispose]()` / `scope[Symbol.asyncDispose]()`
425
+
426
+ Disposes all managed 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`.
427
+
428
+ ### `ContainerError`
429
+
430
+ Error class thrown for container failures such as resolving an unregistered token, circular dependencies, or operations on a disposed container/scope.
431
+
432
+ ### `Resolver`
433
+
434
+ Type export representing the resolver passed to factory callbacks. Useful when you need to type a function that accepts a resolver parameter.
435
+
436
+ ## License
437
+
438
+ MIT