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.ko.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
+ 완전한 타입 추론을 지원하는 경량 TypeScript DI 컨테이너.
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
+ > 이름은 일본어 型紙 *(katagami)*에서 유래합니다 — 전통 일본 염색에서 정밀한 문양을 직물에 옮기는 데 사용되는 정교한 형지입니다. 여러 형지를 겹쳐 복잡한 무늬를 구성하듯, 메서드 체인의 각 호출마다 타입이 축적됩니다. 형지는 종이와 붓만 있으면 되고, 정교한 기계 장치가 필요 없습니다 — 마찬가지로 Katagami는 데코레이터나 메타데이터 메커니즘 없이 어떤 빌드 도구에서든 바로 사용할 수 있습니다. 그리고 다양한 직물과 기법에 적용되는 형지처럼, Katagami는 TypeScript와 JavaScript, 클래스 토큰과 PropertyKey 토큰에 걸쳐 — 엄격하고 조합 가능한 DI를 위한 하이브리드 접근 방식을 제공합니다.
12
+
13
+ ## 특징
14
+
15
+ | 특징 | 설명 |
16
+ | -------------------- | ---------------------------------------------------------------------------------- |
17
+ | 완전한 타입 추론 | 메서드 체이닝으로 타입이 축적되며, 미등록 토큰 해석은 컴파일 타임 오류 발생 |
18
+ | 세 가지 라이프타임 | Singleton, Transient, Scoped (자식 컨테이너 지원) |
19
+ | 비동기 팩토리 | Promise를 반환하는 팩토리는 타입 시스템이 자동 추적 |
20
+ | 순환 의존성 감지 | 전체 순환 경로를 포함하는 명확한 오류 메시지 |
21
+ | Disposable 지원 | TC39 명시적 리소스 관리 (`Symbol.dispose` / `Symbol.asyncDispose` / `await using`) |
22
+ | 캡티브 의존성 방지 | Singleton/Transient 팩토리는 Scoped 토큰에 접근 불가; 컴파일 타임에 감지 |
23
+ | 선택적 해석 | `tryResolve`는 미등록 토큰 시 throw 대신 `undefined` 반환 |
24
+ | 하이브리드 토큰 전략 | 클래스 토큰으로 엄격한 타입 안전성, PropertyKey 토큰으로 유연성 |
25
+ | 인터페이스 타입 맵 | `createContainer<T>()`에 인터페이스를 전달하여 등록 순서 무관한 등록 |
26
+ | 제로 의존성 | 데코레이터 불필요, reflect-metadata 불필요, 폴리필 불필요 |
27
+
28
+ ## 설치
29
+
30
+ ```bash
31
+ npm install katagami
32
+ ```
33
+
34
+ ## 빠른 시작
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 (완전히 추론됨)
58
+ userService.greet('world');
59
+ ```
60
+
61
+ ## 왜 Katagami인가
62
+
63
+ 대부분의 TypeScript DI 컨테이너는 데코레이터, reflect-metadata 또는 문자열 기반 토큰에 의존하며 — 각각 도구 호환성, 타입 안전성 또는 번들 크기에서 트레이드오프를 수반합니다. Katagami는 다른 접근 방식을 취합니다.
64
+
65
+ ### 데코레이터 불필요, reflect-metadata 불필요
66
+
67
+ 데코레이터 기반 DI는 `experimentalDecorators` 및 `emitDecoratorMetadata` 컴파일러 옵션이 필요합니다. esbuild와 Vite(기본 설정) 같은 최신 빌드 도구는 `emitDecoratorMetadata`를 지원하지 않으며, TC39 표준 데코레이터 제안에도 자동 타입 메타데이터 생성에 해당하는 기능이 포함되어 있지 않습니다. Katagami는 이 중 어느 것에도 의존하지 않으므로 — 어떤 빌드 도구에서든 바로 사용할 수 있습니다.
68
+
69
+ ### 클래스 토큰을 통한 완전한 타입 추론
70
+
71
+ 문자열 토큰 DI는 토큰에서 타입으로의 수동 매핑을 유지해야 합니다. 매개변수 이름 매칭은 코드 압축 시 깨집니다. Katagami는 클래스를 직접 토큰으로 사용하므로, `resolve`가 올바른 반환 타입을 — 동기 또는 `Promise` — 추가 어노테이션 없이 자동으로 추론합니다.
72
+
73
+ ### 메서드 체인 타입 축적
74
+
75
+ 각 `register` 호출마다 타입이 축적됩니다. 팩토리 내부에서 리졸버는 체인의 해당 시점까지 등록된 토큰만 받아들입니다. 미등록 토큰을 해석하면 런타임 오류가 아닌 컴파일 타임 오류가 발생합니다.
76
+
77
+ ### 하이브리드 토큰 전략
78
+
79
+ 클래스 토큰은 메서드 체이닝을 통해 엄격한 순서 의존적 타입 안전성을 제공합니다. 하지만 때로는 서비스 세트를 미리 정의하고 임의의 순서로 등록하고 싶을 수 있습니다. `createContainer<T>()`에 인터페이스를 전달하고 PropertyKey 토큰을 사용하세요 — 타입 맵은 생성 시점에 고정되므로 등록 순서는 중요하지 않습니다.
80
+
81
+ ### 제로 의존성
82
+
83
+ 런타임 의존성 없음, 폴리필 없음. reflect-metadata(비압축 약 50 KB)를 번들에 추가할 필요가 없습니다.
84
+
85
+ ## 가이드
86
+
87
+ ### Singleton과 Transient
88
+
89
+ Singleton은 첫 번째 `resolve`에서 인스턴스를 생성하고 캐시합니다. Transient는 매번 새로운 인스턴스를 생성합니다.
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 — 매번 같은 인스턴스
107
+ container.resolve(Database) === container.resolve(Database); // true
108
+
109
+ // Transient — 매번 새로운 인스턴스
110
+ container.resolve(RequestHandler) === container.resolve(RequestHandler); // false
111
+ ```
112
+
113
+ ### Scoped 라이프타임과 자식 컨테이너
114
+
115
+ Scoped 등록은 스코프 내에서는 Singleton처럼 동작하지만 각 새로운 스코프에서 새 인스턴스를 생성합니다. `createScope()`를 사용하여 자식 컨테이너를 생성합니다. Scoped 토큰은 루트 컨테이너에서 해석할 수 없습니다.
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
+ // 각 요청마다 스코프 생성
133
+ const scope1 = root.createScope();
134
+ const scope2 = root.createScope();
135
+
136
+ // Scoped — 같은 스코프 내에서는 동일, 스코프 간에는 다름
137
+ scope1.resolve(RequestContext) === scope1.resolve(RequestContext); // true
138
+ scope1.resolve(RequestContext) === scope2.resolve(RequestContext); // false
139
+
140
+ // Singleton — 모든 스코프에서 공유
141
+ scope1.resolve(DbPool) === scope2.resolve(DbPool); // true
142
+ ```
143
+
144
+ 스코프는 중첩할 수도 있습니다. 각 중첩 스코프는 자체 Scoped 인스턴스 캐시를 가지면서 부모와 Singleton을 공유합니다:
145
+
146
+ ```ts
147
+ const parentScope = root.createScope();
148
+ const childScope = parentScope.createScope();
149
+
150
+ // 각 중첩 스코프는 독립적인 Scoped 인스턴스를 가짐
151
+ parentScope.resolve(RequestContext) === childScope.resolve(RequestContext); // false
152
+
153
+ // Singleton은 여전히 공유됨
154
+ parentScope.resolve(DbPool) === childScope.resolve(DbPool); // true
155
+ ```
156
+
157
+ ### 비동기 팩토리
158
+
159
+ `Promise`를 반환하는 팩토리는 타입 시스템이 자동으로 추적합니다. 비동기 토큰을 `resolve`하면 반환 타입은 `V`가 아닌 `Promise<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)); // 비동기 초기화 시뮬레이션
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> (await 후 → Database)
186
+ db.connected; // true
187
+ ```
188
+
189
+ 비동기 팩토리는 동기 및 비동기 등록 모두에 의존할 수 있습니다:
190
+
191
+ ```ts
192
+ const container = createContainer()
193
+ .registerSingleton(Logger, () => new Logger())
194
+ .registerSingleton(Database, async r => {
195
+ const logger = r.resolve(Logger); // 동기 → Logger
196
+ logger.log('연결 중...');
197
+ return new Database(true);
198
+ });
199
+ ```
200
+
201
+ ### 순환 의존성 감지
202
+
203
+ Katagami는 현재 해석 중인 토큰을 추적합니다. 순환 의존성이 발견되면 전체 순환 경로를 포함하는 `ContainerError`가 발생합니다:
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
+ 간접적인 순환도 감지됩니다:
225
+
226
+ ```
227
+ ContainerError: Circular dependency detected: ServiceX -> ServiceY -> ServiceZ -> ServiceX
228
+ ```
229
+
230
+ ### Disposable 지원
231
+
232
+ `Container`와 `Scope` 모두 `AsyncDisposable`을 구현합니다. 폐기 시 관리 대상 인스턴스가 생성 역순(LIFO)으로 순회되며, `[Symbol.asyncDispose]()` 또는 `[Symbol.dispose]()` 메서드가 자동으로 호출됩니다.
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
+ // 수동 폐기
244
+ const container = createContainer().registerSingleton(Connection, () => new Connection());
245
+
246
+ container.resolve(Connection);
247
+ await container[Symbol.asyncDispose]();
248
+ // => "Connection closed"
249
+ ```
250
+
251
+ `await using`을 사용하면 블록 끝에서 스코프가 자동으로 폐기됩니다:
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
+ // ... conn 사용 ...
262
+ } // 여기서 스코프가 폐기됨 — Connection이 정리되고, DbPool은 영향 없음
263
+ ```
264
+
265
+ 스코프 폐기는 Scoped 인스턴스에만 영향을 줍니다. Singleton 인스턴스는 루트 컨테이너가 소유하며, 컨테이너 자체가 폐기될 때 폐기됩니다.
266
+
267
+ ### 인터페이스 타입 맵
268
+
269
+ `createContainer<T>()`에 인터페이스를 전달하면 PropertyKey 토큰의 타입이 체이닝 축적이 아닌 인터페이스에서 결정됩니다. 따라서 토큰을 임의의 순서로 등록하고 해석할 수 있습니다:
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'은 'logger'보다 먼저 등록되어도 참조 가능
287
+ .registerSingleton('greeting', r => {
288
+ r.resolve('logger').log('greeting 구성 중...');
289
+ return 'Hello!';
290
+ })
291
+ .registerSingleton('logger', () => new Logger());
292
+
293
+ const greeting = container.resolve('greeting');
294
+ // ^? string
295
+ ```
296
+
297
+ ### 하이브리드 토큰 전략
298
+
299
+ 두 가지 접근 방식을 혼합할 수 있습니다 — 클래스 토큰으로 순서 의존적 타입 안전성을, PropertyKey 토큰으로 순서 무관한 유연성을 활용하세요:
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('greeting 구성 중...');
307
+ return 'Hello!';
308
+ });
309
+ ```
310
+
311
+ ### 캡티브 의존성 방지
312
+
313
+ "캡티브 의존성"은 긴 라이프타임의 서비스(Singleton 또는 Transient)가 짧은 라이프타임의 서비스(Scoped)를 캡처하여 의도한 스코프를 넘어 유지시키는 문제입니다. Katagami는 이를 컴파일 타임에 방지합니다 — Singleton과 Transient 팩토리는 비 Scoped 토큰으로 제한된 리졸버만 받습니다:
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 팩토리는 Scoped 토큰을 해석할 수 없음
324
+ .registerSingleton(DbPool, r => new DbPool(r.resolve(RequestContext)));
325
+ ```
326
+
327
+ 반면 Scoped 팩토리는 Scoped 토큰과 비 Scoped 토큰 모두를 해석할 수 있습니다:
328
+
329
+ ```ts
330
+ const container = createContainer()
331
+ .registerSingleton(DbPool, () => new DbPool())
332
+ .registerScoped(RequestContext, r => {
333
+ r.resolve(DbPool); // OK — Scoped 팩토리는 Singleton 토큰을 해석할 수 있음
334
+ return new RequestContext();
335
+ });
336
+ ```
337
+
338
+ ### 선택적 해석 (tryResolve)
339
+
340
+ 선택적 의존성을 처리하거나 오류 없이 토큰이 등록되었는지 확인하려면 `tryResolve`를 사용하세요. `resolve`와 달리 미등록 토큰에 대해 `ContainerError`를 throw하는 대신 `undefined`를 반환합니다:
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는 미등록 토큰에 대해 throw
360
+ container.resolve(Analytics); // ContainerError: Token "Analytics" is not registered.
361
+
362
+ // tryResolve는 미등록 토큰에 대해 undefined 반환
363
+ const analytics = container.tryResolve(Analytics);
364
+ // ^? Analytics | undefined
365
+ if (analytics) {
366
+ analytics.track('event');
367
+ }
368
+ ```
369
+
370
+ `tryResolve`는 팩토리 내 선택적 의존성에 특히 유용합니다. `resolve`와 달리 미등록 토큰에 대한 컴파일 타임 오류가 발생하지 않습니다:
371
+
372
+ ```ts
373
+ const container = createContainer()
374
+ .registerSingleton(Logger, () => new Logger())
375
+ .registerSingleton('UserService', r => {
376
+ const logger = r.tryResolve(Logger); // 선택적 의존성
377
+ const analytics = r.tryResolve(Analytics); // Analytics가 미등록이지만 컴파일 오류 없음
378
+
379
+ return {
380
+ greet(name: string) {
381
+ logger?.log(`Hello, ${name}`);
382
+ analytics?.track('user_greeted');
383
+ },
384
+ };
385
+ });
386
+ ```
387
+
388
+ `tryResolve`는 순환 의존성과 폐기된 컨테이너/스코프 작업에 대해서는 여전히 `ContainerError`를 throw합니다 — 미등록 토큰만 `undefined`를 반환합니다.
389
+
390
+ ## API
391
+
392
+ ### `createContainer<T, ScopedT>()`
393
+
394
+ 새로운 DI 컨테이너를 생성합니다. PropertyKey 토큰의 타입 맵을 정의하려면 인터페이스를 `T`로 전달합니다. Scoped PropertyKey 토큰의 별도 타입 맵을 정의하려면 `ScopedT`를 전달합니다(`T`와 마찬가지로 등록 순서 무관).
395
+
396
+ ### `container.registerSingleton(token, factory)`
397
+
398
+ 팩토리를 Singleton으로 등록합니다. 인스턴스는 첫 번째 `resolve`에서 생성되고 이후 캐시됩니다. 메서드 체이닝을 위해 컨테이너를 반환합니다.
399
+
400
+ ### `container.registerTransient(token, factory)`
401
+
402
+ 팩토리를 Transient로 등록합니다. 매 `resolve`마다 새로운 인스턴스가 생성됩니다. 메서드 체이닝을 위해 컨테이너를 반환합니다.
403
+
404
+ ### `container.registerScoped(token, factory)`
405
+
406
+ 팩토리를 Scoped로 등록합니다. 스코프 내에서 인스턴스는 첫 번째 `resolve`에서 생성되고 해당 스코프에 캐시됩니다. 각 스코프는 자체 캐시를 유지합니다. Scoped 토큰은 루트 컨테이너에서 해석할 수 없습니다. 메서드 체이닝을 위해 컨테이너를 반환합니다.
407
+
408
+ ### `container.resolve(token)`
409
+
410
+ 주어진 토큰의 인스턴스를 해석하여 반환합니다. 토큰이 미등록이거나 순환 의존성이 감지되면 `ContainerError`를 throw합니다.
411
+
412
+ ### `container.tryResolve(token)` / `scope.tryResolve(token)`
413
+
414
+ 주어진 토큰의 인스턴스 해석을 시도합니다. 토큰이 미등록이면 throw하는 대신 `undefined`를 반환합니다. 순환 의존성이나 폐기된 컨테이너/스코프 작업에 대해서는 여전히 `ContainerError`를 throw합니다.
415
+
416
+ ### `container.createScope()`
417
+
418
+ 새로운 `Scope`(자식 컨테이너)를 생성합니다. 스코프는 부모의 모든 등록을 상속합니다. Singleton 인스턴스는 부모와 공유되며, Scoped 인스턴스는 스코프 로컬입니다.
419
+
420
+ ### `Scope`
421
+
422
+ `createScope()`로 생성되는 스코프 자식 컨테이너입니다. `resolve(token)`, `tryResolve(token)`, `createScope()`(중첩 스코프용), `[Symbol.asyncDispose]()`를 제공합니다.
423
+
424
+ ### `container[Symbol.asyncDispose]()` / `scope[Symbol.asyncDispose]()`
425
+
426
+ 모든 관리 대상 인스턴스를 생성 역순(LIFO)으로 폐기합니다. 각 인스턴스의 `[Symbol.asyncDispose]()` 또는 `[Symbol.dispose]()`를 호출합니다. 멱등 — 이후 호출은 아무 작업도 하지 않습니다. 폐기 후 `resolve()`와 `createScope()`는 `ContainerError`를 throw합니다.
427
+
428
+ ### `ContainerError`
429
+
430
+ 미등록 토큰 해석, 순환 의존성, 폐기된 컨테이너/스코프에 대한 작업 등 컨테이너 실패 시 throw되는 오류 클래스입니다.
431
+
432
+ ### `Resolver`
433
+
434
+ 팩토리 콜백에 전달되는 리졸버를 나타내는 타입 export입니다. 리졸버 매개변수를 받는 함수에 타입을 지정할 때 유용합니다.
435
+
436
+ ## 라이선스
437
+
438
+ MIT