@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.
- package/README.md +176 -103
- package/dist/cli.js +7 -5
- 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.
|
|
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
|
-
|
|
29
|
+
Extend `ImplScan` to tell the generator to collect the interface's implementations:
|
|
64
30
|
|
|
65
|
-
|
|
66
|
-
|
|
31
|
+
```ts
|
|
32
|
+
// payment-handler.ts
|
|
33
|
+
import type { ImplScan } from '@thomasrepnik/ts-implscan';
|
|
67
34
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
35
|
+
export interface PaymentHandler extends ImplScan {
|
|
36
|
+
name: string;
|
|
37
|
+
pay(amount: number): string;
|
|
38
|
+
}
|
|
39
|
+
```
|
|
71
40
|
|
|
72
|
-
|
|
73
|
-
then you have to register every implementation by hand, and it's easy to forget one.
|
|
41
|
+
### 2. Implement it
|
|
74
42
|
|
|
75
|
-
|
|
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
|
-
//
|
|
81
|
-
export
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
122
|
-
|
|
123
|
-
| `--project`
|
|
124
|
-
| `--src`
|
|
125
|
-
| `--out`
|
|
126
|
-
| `--target`
|
|
127
|
-
| `--dry-run`
|
|
128
|
-
| `-h, --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
|
|
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
|
|
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
|
|
25
|
-
|
|
26
|
-
--
|
|
27
|
-
--
|
|
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.
|
|
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",
|