@thomasrepnik/ts-implscan 0.1.0 → 0.1.3

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.
Files changed (3) hide show
  1. package/README.md +180 -105
  2. package/dist/cli.js +7 -5
  3. package/package.json +3 -3
package/README.md CHANGED
@@ -3,7 +3,9 @@
3
3
  [![npm](https://img.shields.io/npm/v/@thomasrepnik/ts-implscan)](https://www.npmjs.com/package/@thomasrepnik/ts-implscan)
4
4
  [![CI](https://github.com/thomasrepnik/ts-implscan/actions/workflows/ci.yml/badge.svg)](https://github.com/thomasrepnik/ts-implscan/actions/workflows/ci.yml)
5
5
 
6
- **Spring-style `List<A>` injection for TypeScript.** Find every `implements A` and generate the registry.
6
+ **Spring-style `List<PaymentHandler>` injection for TypeScript.** Mark an interface with `ImplScan`, and a
7
+ build-time generator collects every class that implements it into a typed registry. For Angular,
8
+ it also generates the `InjectionToken`s and providers.
7
9
 
8
10
  ## Installation
9
11
 
@@ -24,63 +26,95 @@ You need Node 22 or newer.
24
26
 
25
27
  ## Quick start
26
28
 
27
- 1. **Mark an interface** by extending `ImplScan`:
28
- ```ts
29
- import type { ImplScan } from '@thomasrepnik/ts-implscan';
30
-
31
- export interface PaymentHandler extends ImplScan {
32
- name: string;
33
- pay(amount: number): string;
34
- }
35
- ```
36
- 2. **Write implementations** as usual, anywhere under `src/`:
37
- ```ts
38
- export class CardHandler implements PaymentHandler { ... }
39
- export class PaypalHandler implements PaymentHandler { ... }
40
- ```
41
- 3. **Generate the registry**, ideally before every start and build:
42
- ```json
43
- "scripts": {
44
- "generate": "ts-implscan --target angular",
45
- "prestart": "npm run generate",
46
- "prebuild": "npm run generate"
47
- }
48
- ```
49
- 4. **Use it.** In Angular, register everything once and inject the list anywhere:
50
- ```ts
51
- // app.config.ts
52
- import { provideImplscan } from './generated/registry';
53
- export const appConfig: ApplicationConfig = { providers: [provideImplscan()] };
54
-
55
- // any component or service: the equivalent of Spring's List<PaymentHandler>
56
- import { PAYMENT_HANDLER_LIST } from './generated/registry';
57
- private readonly handlers = inject(PAYMENT_HANDLER_LIST); // PaymentHandler[]
58
- ```
59
-
60
- A new implementation is picked up the next time you regenerate. A new interface only needs
61
- `extends ImplScan`; its token and providers are generated too.
29
+ ### 1. Mark an interface
62
30
 
63
- ## Why
31
+ Extend `ImplScan` to tell the generator to collect the interface's implementations:
64
32
 
65
- In Spring you can inject `List<PaymentHandler>`, and the container hands you every bean that
66
- implements the interface. TypeScript can't do this out of the box, for two reasons:
33
+ ```ts
34
+ // payment-handler.ts
35
+ import type { ImplScan } from '@thomasrepnik/ts-implscan';
67
36
 
68
- 1. **Interfaces are erased.** At runtime nothing records that a class implements `PaymentHandler`.
69
- 2. **There is no classpath scanning.** JavaScript only runs files that something imports, so a
70
- new implementation that nobody imports doesn't exist at runtime.
37
+ export interface PaymentHandler extends ImplScan {
38
+ name: string;
39
+ pay(amount: number): string;
40
+ }
41
+ ```
71
42
 
72
- DI containers such as Angular, tsyringe or InversifyJS solve this with injection tokens, but
73
- then you have to register every implementation by hand, and it's easy to forget one.
43
+ ### 2. Implement it
74
44
 
75
- `ts-implscan` moves the scan to build time. It uses [ts-morph](https://ts-morph.com) and the
76
- TypeScript type checker to find every exported class that `implements` a marked interface, and
77
- writes a typed registry file:
45
+ Write implementations as usual, anywhere under `src/`. They must be exported:
78
46
 
79
47
  ```ts
80
- // src/generated/registry.ts – GENERATED, do not edit
81
- export const PaymentHandlerImplementations: Array<new (...args: any[]) => PaymentHandler> = [CardHandler, PaypalHandler];
48
+ // card-handler.ts
49
+ export class CardHandler implements PaymentHandler {
50
+ name = 'card';
51
+ pay(amount: number) {
52
+ return `charged ${amount} to credit card`;
53
+ }
54
+ }
55
+
56
+ // paypal-handler.ts
57
+ export class PaypalHandler implements PaymentHandler { ... }
58
+ ```
59
+
60
+ ### 3. Generate the registry and use it
61
+
62
+ Pick the variant for your project.
63
+
64
+ #### With Angular
65
+
66
+ Add the generator to your scripts, so it runs before every `ng serve` and `ng build`:
67
+
68
+ ```json
69
+ "scripts": {
70
+ "generate": "ts-implscan --target angular --project tsconfig.app.json --out src/app/generated/implscan-registry.ts",
71
+ "prestart": "npm run generate",
72
+ "prebuild": "npm run generate"
73
+ }
74
+ ```
75
+
76
+ Register all implementations once:
82
77
 
83
- // only with --target angular
78
+ ```ts
79
+ // app.config.ts
80
+ import { provideImplscan } from './generated/implscan-registry';
81
+
82
+ export const appConfig: ApplicationConfig = {
83
+ providers: [provideImplscan()],
84
+ };
85
+ ```
86
+
87
+ Inject the list anywhere. This is the equivalent of Spring's `List<PaymentHandler>`:
88
+
89
+ ```ts
90
+ import { PAYMENT_HANDLER_LIST } from './generated/implscan-registry';
91
+
92
+ @Injectable({ providedIn: 'root' })
93
+ export class PaymentService {
94
+ private readonly handlers = inject(PAYMENT_HANDLER_LIST); // PaymentHandler[]
95
+
96
+ payWithAll(amount: number) {
97
+ return this.handlers.map((handler) => handler.pay(amount));
98
+ }
99
+ }
100
+ ```
101
+
102
+ Implementations can use DI themselves, for example `private readonly http = inject(HttpClient)`.
103
+ Mark them with `@Injectable()`, but **without** `providedIn: 'root'`. `provideImplscan()` already
104
+ creates one instance per implementation, so a root-provided implementation would exist twice.
105
+
106
+ <details>
107
+ <summary>What gets generated</summary>
108
+
109
+ ```ts
110
+ // src/app/generated/implscan-registry.ts
111
+ // GENERATED by ts-implscan – do not edit
112
+ import { InjectionToken, type Provider } from '@angular/core';
113
+ import type { PaymentHandler } from '../payment/payment-handler';
114
+ import { CardHandler } from '../payment/card-handler';
115
+ import { PaypalHandler } from '../payment/paypal-handler';
116
+
117
+ export const PaymentHandlerImplementations: Array<new (...args: any[]) => PaymentHandler> = [CardHandler, PaypalHandler];
84
118
  export const PAYMENT_HANDLER_LIST = new InjectionToken<PaymentHandler[]>('PAYMENT_HANDLER_LIST');
85
119
 
86
120
  export function provideImplscan(): Provider[] {
@@ -91,7 +125,84 @@ export function provideImplscan(): Provider[] {
91
125
  }
92
126
  ```
93
127
 
94
- The registry is ordinary TypeScript, so `tsc` fails if a listed class doesn't actually satisfy
128
+ There is one token per marked interface, and `provideImplscan()` registers all of them.
129
+ </details>
130
+
131
+ The [example](example/main-angular.ts) in this repository resolves the generated providers
132
+ through Angular's real injector (`npm run start:angular`).
133
+
134
+ #### Without Angular
135
+
136
+ The default target generates plain arrays of classes, with no framework dependency. It works for
137
+ Node, other frameworks, or plain TypeScript:
138
+
139
+ ```json
140
+ "scripts": {
141
+ "generate": "ts-implscan",
142
+ "prestart": "npm run generate",
143
+ "prebuild": "npm run generate"
144
+ }
145
+ ```
146
+
147
+ ```ts
148
+ import { PaymentHandlerImplementations } from './generated/implscan-registry';
149
+
150
+ const handlers = PaymentHandlerImplementations.map((Handler) => new Handler()); // PaymentHandler[]
151
+
152
+ for (const handler of handlers) {
153
+ console.log(handler.pay(42));
154
+ }
155
+ ```
156
+
157
+ If your implementations have dependencies, register the classes from the array with your DI
158
+ container instead of calling `new`.
159
+
160
+ <details>
161
+ <summary>What gets generated</summary>
162
+
163
+ ```ts
164
+ // src/generated/implscan-registry.ts
165
+ // GENERATED by ts-implscan – do not edit
166
+ import type { PaymentHandler } from '../payment/payment-handler';
167
+ import { CardHandler } from '../payment/card-handler';
168
+ import { PaypalHandler } from '../payment/paypal-handler';
169
+
170
+ export const PaymentHandlerImplementations: Array<new (...args: any[]) => PaymentHandler> = [CardHandler, PaypalHandler];
171
+ ```
172
+ </details>
173
+
174
+ The [example](example/main.ts) in this repository does exactly this (`npm start`).
175
+
176
+ **That's it.** A new implementation is picked up the next time the generator runs. A new
177
+ interface only needs `extends ImplScan`, and with Angular its token is generated too.
178
+
179
+ ## Good to know
180
+
181
+ - **Check what would be collected** without writing anything: `npx ts-implscan --dry-run`.
182
+ - **Replace a whole list in Angular tests** with one provider:
183
+ `providers: [{ provide: PAYMENT_HANDLER_LIST, useValue: [fakeHandler] }]`.
184
+ - **A marked interface without implementations** gives an empty array, and in Angular a
185
+ `useValue: []` provider, so injecting it doesn't throw.
186
+ - **Commit the generated file**, so the IDE and type checks work right after a checkout. Let CI
187
+ catch a forgotten regeneration with `npm run generate && git diff --exit-code`.
188
+ - **In CI**, `prebuild` only runs through `npm run build`. If your pipeline calls `ng build`
189
+ directly, run `npm run generate` first.
190
+
191
+ ## Why
192
+
193
+ In Spring you can inject `List<PaymentHandler>`, and the container hands you every bean that
194
+ implements the interface. TypeScript can't do this out of the box, for two reasons:
195
+
196
+ 1. **Interfaces are erased.** At runtime nothing records that a class implements `PaymentHandler`.
197
+ 2. **There is no classpath scanning.** JavaScript only runs files that something imports, so a
198
+ new implementation that nobody imports doesn't exist at runtime.
199
+
200
+ DI containers such as Angular, tsyringe or InversifyJS solve this with injection tokens, but
201
+ then you have to register every implementation by hand, and it's easy to forget one.
202
+
203
+ `ts-implscan` moves the scan to build time. It uses [ts-morph](https://ts-morph.com) and the
204
+ TypeScript type checker to find every exported class that `implements` a marked interface. The
205
+ generated file is ordinary TypeScript, so `tsc` fails if a listed class doesn't actually satisfy
95
206
  its interface.
96
207
 
97
208
  **Why a marker interface rather than a comment tag like `/** @implscan */`?** The marker is
@@ -104,7 +215,7 @@ reasons, such as Angular's `OnInit`.
104
215
 
105
216
  - **Plugin and strategy patterns**, such as payment providers, exporters, validators, notification
106
217
  channels or feature handlers, where the set of implementations keeps growing.
107
- - **Teams coming from Spring or Java** who want the open/closed principle that `List<A>` injection
218
+ - **Teams coming from Spring or Java** who want the open/closed principle that `List<PaymentHandler>` injection
108
219
  gives them: adding behaviour means adding a class, not editing a central list.
109
220
  - **Angular apps** with many multi-provider tokens, so nobody forgets to register a new
110
221
  implementation, or to write the token.
@@ -115,65 +226,23 @@ is fine then.
115
226
  ## CLI options
116
227
 
117
228
  ```sh
118
- ts-implscan [--project tsconfig.json] [--src src] [--out src/generated/registry.ts] [--target plain|angular] [--dry-run]
229
+ ts-implscan [--project tsconfig.json] [--src src] [--out src/generated/implscan-registry.ts] [--target plain|angular] [--dry-run]
119
230
  ```
120
231
 
121
- | Option | Default | Meaning |
122
- |-------------|-----------------------------|--------------------------------------------------------------|
123
- | `--project` | `tsconfig.json` | tsconfig to read compiler options from (paths, resolution) |
124
- | `--src` | `src` | Folder to scan. `*.spec.ts` and `*.d.ts` are skipped. |
125
- | `--out` | `src/generated/registry.ts` | File to generate |
126
- | `--target` | `plain` | `angular` adds an `InjectionToken` per interface and `provideImplscan()` |
127
- | `--dry-run` | off | Print the result to stdout instead of writing it |
128
- | `-h, --help`| | Show the help |
129
-
130
- Use `--dry-run` to check which classes would be collected, without writing anything.
232
+ | Option | Default | Meaning |
233
+ |--------------|--------------------------------------|----------------------------------------------------------|
234
+ | `--project` | `tsconfig.json` | tsconfig to read compiler options from (paths, resolution) |
235
+ | `--src` | `src` | Folder to scan. `*.spec.ts` and `*.d.ts` are skipped. |
236
+ | `--out` | `src/generated/implscan-registry.ts` | File to generate |
237
+ | `--target` | `plain` | `angular` adds an `InjectionToken` per interface and `provideImplscan()` |
238
+ | `--dry-run` | off | Print the result to stdout instead of writing it |
239
+ | `-h, --help` | | Show the help |
131
240
 
132
241
  The files to scan come from `--src`, not from the tsconfig, because a new implementation isn't
133
242
  imported anywhere yet. The tsconfig is only read for compiler options. The import extension in
134
243
  the generated file follows it too: `.ts` with `allowImportingTsExtensions`, `.js` with `NodeNext`,
135
244
  and no extension with `bundler`, which is what Angular uses.
136
245
 
137
- ## Angular
138
-
139
- A typical setup in an Angular CLI project:
140
-
141
- ```json
142
- "generate": "ts-implscan --target angular --project tsconfig.app.json --out src/app/generated/registry.ts"
143
- ```
144
-
145
- - **Implementations can use DI themselves**, for example `private readonly http = inject(HttpClient)`.
146
- Angular creates them through `useClass`, so `inject()` works inside them.
147
- - **Mark them with `@Injectable()`, but without `providedIn: 'root'`.** `useClass` creates its own
148
- instance, so a root-provided class would exist twice.
149
- - **In tests, replace a whole list** with one provider:
150
- `providers: [{ provide: PAYMENT_HANDLER_LIST, useValue: [fakeHandler] }]`.
151
- - **A marked interface without any implementation** still gets a provider (`useValue: []`), so
152
- injecting it returns an empty list instead of throwing.
153
-
154
- ### Without the generated tokens (`--target plain`)
155
-
156
- The plain target only generates the arrays of classes, with no Angular dependency. You can use
157
- it with any framework, or none at all:
158
-
159
- - **Plain `new`:** `PaymentHandlerImplementations.map((C) => new C())`. This only works for
160
- classes without dependencies. In Angular, `inject()` outside a DI context throws `NG0203`.
161
- - **Angular, each class `providedIn: 'root'`**, and the consumer calls
162
- `PaymentHandlerImplementations.map((C) => inject(C))`. Dependencies work, but every consumer
163
- imports the generated file, and a test has to override each class one by one.
164
-
165
- ## In CI
166
-
167
- `prebuild` only runs through `npm run build`. If your pipeline calls `ng build` directly, run
168
- `npm run generate` first.
169
-
170
- Commit the generated file, so the IDE and type checks work right after a checkout. Then let CI
171
- catch a forgotten regeneration:
172
-
173
- ```sh
174
- npm run generate && git diff --exit-code
175
- ```
176
-
177
246
  ## Limits
178
247
 
179
248
  - Only interfaces that extend `ImplScan` **directly** are collected. The CLI detects the marker by
@@ -203,7 +272,7 @@ injector in Node. The published package only depends on `ts-morph`.
203
272
 
204
273
  ### Releasing
205
274
 
206
- Releases are published to npm by `.github/workflows/release.yml`, using npm
275
+ Releases are uploaded to npm by `.github/workflows/release.yml`, using npm
207
276
  [trusted publishing](https://docs.npmjs.com/trusted-publishers). It doesn't need an npm token.
208
277
 
209
278
  ```sh
@@ -213,7 +282,13 @@ gh release create v0.2.0 --generate-notes # or click "Publish release" on GitH
213
282
  ```
214
283
 
215
284
  Publishing the GitHub release starts the workflow. It checks that the tag matches
216
- `package.json`, type-checks, runs the package smoke test and publishes.
285
+ `package.json`, type-checks, runs the package smoke test and **stages** the version on npm.
286
+ A staged version isn't public until a maintainer approves it with 2FA:
287
+
288
+ ```sh
289
+ npm stage list @thomasrepnik/ts-implscan # shows the stage id
290
+ npm stage approve <stage-id>
291
+ ```
217
292
 
218
293
  ## License
219
294
 
package/dist/cli.js CHANGED
@@ -12,7 +12,7 @@ const { values: args } = parseArgs({
12
12
  options: {
13
13
  project: { type: 'string', default: 'tsconfig.json' },
14
14
  src: { type: 'string', default: 'src' },
15
- out: { type: 'string', default: 'src/generated/registry.ts' },
15
+ out: { type: 'string', default: 'src/generated/implscan-registry.ts' },
16
16
  target: { type: 'string', default: 'plain' },
17
17
  'dry-run': { type: 'boolean', default: false },
18
18
  help: { type: 'boolean', short: 'h', default: false },
@@ -21,10 +21,12 @@ const { values: args } = parseArgs({
21
21
  if (args.help) {
22
22
  console.log(`Usage: ts-implscan [options]
23
23
 
24
- --project <file> tsconfig to read compiler options from (default: tsconfig.json)
25
- --src <dir> folder to scan (default: src)
26
- --out <file> file to generate (default: src/generated/registry.ts)
27
- --target <target> plain | angular (default: plain)
24
+ --project <file> tsconfig to read compiler options from
25
+ (default: tsconfig.json)
26
+ --src <dir> folder to scan (default: src)
27
+ --out <file> file to generate
28
+ (default: src/generated/implscan-registry.ts)
29
+ --target <target> plain | angular (default: plain)
28
30
  --dry-run print the result instead of writing it
29
31
  -h, --help show this help`);
30
32
  process.exit(0);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@thomasrepnik/ts-implscan",
3
- "version": "0.1.0",
4
- "description": "Spring-style List<A> injection for TypeScript: find every class implementing a marked interface and generate a typed registry, plus Angular InjectionTokens and providers.",
3
+ "version": "0.1.3",
4
+ "description": "Spring-style List<PaymentHandler> injection for TypeScript: find every class implementing a marked interface and generate a typed registry, plus Angular InjectionTokens and providers.",
5
5
  "keywords": [
6
6
  "typescript",
7
7
  "dependency-injection",
@@ -47,7 +47,7 @@
47
47
  "build": "tsc -p tsconfig.build.json",
48
48
  "prepack": "npm run build",
49
49
  "typecheck": "tsc --noEmit",
50
- "generate": "node src/cli.ts --src example --out example/generated/registry.ts && node src/cli.ts --src example --target angular --out example/generated/registry.angular.ts",
50
+ "generate": "node src/cli.ts --src example --out example/generated/implscan-registry.ts && node src/cli.ts --src example --target angular --out example/generated/implscan-registry.angular.ts",
51
51
  "generate:dry": "node src/cli.ts --src example --dry-run",
52
52
  "generate:dry:angular": "node src/cli.ts --src example --target angular --dry-run",
53
53
  "prestart": "npm run generate",