@thomasrepnik/ts-implscan 0.1.0 → 0.1.2

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