@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.
- package/README.md +180 -105
- package/dist/cli.js +7 -5
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/@thomasrepnik/ts-implscan)
|
|
4
4
|
[](https://github.com/thomasrepnik/ts-implscan/actions/workflows/ci.yml)
|
|
5
5
|
|
|
6
|
-
**Spring-style `List<
|
|
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.
|
|
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
|
-
|
|
31
|
+
Extend `ImplScan` to tell the generator to collect the interface's implementations:
|
|
64
32
|
|
|
65
|
-
|
|
66
|
-
|
|
33
|
+
```ts
|
|
34
|
+
// payment-handler.ts
|
|
35
|
+
import type { ImplScan } from '@thomasrepnik/ts-implscan';
|
|
67
36
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
37
|
+
export interface PaymentHandler extends ImplScan {
|
|
38
|
+
name: string;
|
|
39
|
+
pay(amount: number): string;
|
|
40
|
+
}
|
|
41
|
+
```
|
|
71
42
|
|
|
72
|
-
|
|
73
|
-
then you have to register every implementation by hand, and it's easy to forget one.
|
|
43
|
+
### 2. Implement it
|
|
74
44
|
|
|
75
|
-
|
|
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
|
-
//
|
|
81
|
-
export
|
|
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
|
-
|
|
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
|
-
|
|
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<
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thomasrepnik/ts-implscan",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Spring-style List<
|
|
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",
|