@thomasrepnik/ts-implscan 0.0.0-stage → 0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thomas Repnik
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,220 @@
1
- # Temporary Holding Version
1
+ # ts-implscan
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm](https://img.shields.io/npm/v/@thomasrepnik/ts-implscan)](https://www.npmjs.com/package/@thomasrepnik/ts-implscan)
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
+
6
+ **Spring-style `List<A>` injection for TypeScript.** Find every `implements A` and generate the registry.
7
+
8
+ ## Installation
9
+
10
+ ```sh
11
+ npm install --save-dev @thomasrepnik/ts-implscan
12
+ ```
13
+
14
+ The package contains two things:
15
+
16
+ | What | How you use it |
17
+ |---|---|
18
+ | The `ImplScan` marker interface | `import type { ImplScan } from '@thomasrepnik/ts-implscan'` |
19
+ | The `ts-implscan` CLI (the generator) | `npx ts-implscan`, or in a `package.json` script |
20
+
21
+ It's a dev dependency because it only runs at build time. The `ImplScan` import is type-only and
22
+ disappears when compiled, so nothing from this package ends up in your production bundle.
23
+ You need Node 22 or newer.
24
+
25
+ ## Quick start
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.
62
+
63
+ ## Why
64
+
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:
67
+
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.
71
+
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.
74
+
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:
78
+
79
+ ```ts
80
+ // src/generated/registry.ts – GENERATED, do not edit
81
+ export const PaymentHandlerImplementations: Array<new (...args: any[]) => PaymentHandler> = [CardHandler, PaypalHandler];
82
+
83
+ // only with --target angular
84
+ export const PAYMENT_HANDLER_LIST = new InjectionToken<PaymentHandler[]>('PAYMENT_HANDLER_LIST');
85
+
86
+ export function provideImplscan(): Provider[] {
87
+ return [
88
+ { provide: PAYMENT_HANDLER_LIST, useClass: CardHandler, multi: true },
89
+ { provide: PAYMENT_HANDLER_LIST, useClass: PaypalHandler, multi: true },
90
+ ];
91
+ }
92
+ ```
93
+
94
+ The registry is ordinary TypeScript, so `tsc` fails if a listed class doesn't actually satisfy
95
+ its interface.
96
+
97
+ **Why a marker interface rather than a comment tag like `/** @implscan */`?** The marker is
98
+ checked by the compiler, so a typo doesn't silently drop the interface. Your IDE's "Find usages"
99
+ on `ImplScan` lists every collected interface, and renames are safe. It's the same idea as a Java
100
+ marker interface such as `Serializable`, and it keeps out the interfaces you implement for other
101
+ reasons, such as Angular's `OnInit`.
102
+
103
+ ## When it's helpful
104
+
105
+ - **Plugin and strategy patterns**, such as payment providers, exporters, validators, notification
106
+ 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
108
+ gives them: adding behaviour means adding a class, not editing a central list.
109
+ - **Angular apps** with many multi-provider tokens, so nobody forgets to register a new
110
+ implementation, or to write the token.
111
+
112
+ It's probably overkill if you have two implementations that rarely change. A hand-written array
113
+ is fine then.
114
+
115
+ ## CLI options
116
+
117
+ ```sh
118
+ ts-implscan [--project tsconfig.json] [--src src] [--out src/generated/registry.ts] [--target plain|angular] [--dry-run]
119
+ ```
120
+
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.
131
+
132
+ The files to scan come from `--src`, not from the tsconfig, because a new implementation isn't
133
+ imported anywhere yet. The tsconfig is only read for compiler options. The import extension in
134
+ the generated file follows it too: `.ts` with `allowImportingTsExtensions`, `.js` with `NodeNext`,
135
+ and no extension with `bundler`, which is what Angular uses.
136
+
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
+ ## Limits
178
+
179
+ - Only interfaces that extend `ImplScan` **directly** are collected. The CLI detects the marker by
180
+ its name, so a local `interface ImplScan {}` works too.
181
+ - Only explicit `implements X` clauses count. Classes that only match the interface structurally,
182
+ and subclasses of an implementing class, are not found.
183
+ - Implementations must be exported, non-abstract, named-export classes. A non-exported
184
+ implementation is skipped with a warning.
185
+
186
+ ## Development
187
+
188
+ This repository contains the library in `src/` and a runnable demo in `example/`. The demo has
189
+ two marked interfaces, `PaymentHandler` and `Notifier`.
190
+
191
+ ```sh
192
+ npm install
193
+ npm run generate:dry # print the plain registry for the example, write nothing
194
+ npm run generate:dry:angular # the same with Angular tokens and providers
195
+ npm start # regenerate, then run the example with plain arrays
196
+ npm run start:angular # regenerate, then resolve everything through Angular's real Injector
197
+ npm run typecheck
198
+ npm run test:package # pack, install into an empty project, run the CLI from there
199
+ ```
200
+
201
+ `@angular/core` and `rxjs` are dev dependencies only so that `start:angular` can run Angular's
202
+ injector in Node. The published package only depends on `ts-morph`.
203
+
204
+ ### Releasing
205
+
206
+ Releases are published to npm by `.github/workflows/release.yml`, using npm
207
+ [trusted publishing](https://docs.npmjs.com/trusted-publishers). It doesn't need an npm token.
208
+
209
+ ```sh
210
+ npm version minor # or patch / major: bumps package.json, commits and tags vX.Y.Z
211
+ git push --follow-tags
212
+ gh release create v0.2.0 --generate-notes # or click "Publish release" on GitHub
213
+ ```
214
+
215
+ 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.
217
+
218
+ ## License
219
+
220
+ [MIT](LICENSE)
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env node
2
+ // Build-time "component scan": finds every exported interface that extends the
3
+ // `ImplScan` marker, then every exported class that implements one of them, and
4
+ // writes one array of implementations per interface. With `--target angular` it
5
+ // also writes an InjectionToken per interface and `provideImplscan()`.
6
+ import { mkdirSync, writeFileSync } from 'node:fs';
7
+ import path from 'node:path';
8
+ import { parseArgs } from 'node:util';
9
+ import { Node, Project, ts } from 'ts-morph';
10
+ const MARKER = 'ImplScan';
11
+ const { values: args } = parseArgs({
12
+ options: {
13
+ project: { type: 'string', default: 'tsconfig.json' },
14
+ src: { type: 'string', default: 'src' },
15
+ out: { type: 'string', default: 'src/generated/registry.ts' },
16
+ target: { type: 'string', default: 'plain' },
17
+ 'dry-run': { type: 'boolean', default: false },
18
+ help: { type: 'boolean', short: 'h', default: false },
19
+ },
20
+ });
21
+ if (args.help) {
22
+ console.log(`Usage: ts-implscan [options]
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)
28
+ --dry-run print the result instead of writing it
29
+ -h, --help show this help`);
30
+ process.exit(0);
31
+ }
32
+ if (args.target !== 'plain' && args.target !== 'angular') {
33
+ console.error(`unknown --target '${args.target}', expected 'plain' or 'angular'`);
34
+ process.exit(1);
35
+ }
36
+ const outFile = path.resolve(args.out);
37
+ const outDir = path.dirname(outFile);
38
+ // Use the tsconfig only for compiler options (paths, module resolution). Files come from
39
+ // a glob, because new implementations are not imported anywhere yet (Angular's
40
+ // tsconfig.app.json only lists main.ts).
41
+ const project = new Project({ tsConfigFilePath: args.project, skipAddingFilesFromTsConfig: true });
42
+ project.addSourceFilesAtPaths([`${args.src}/**/*.ts`, `!${args.src}/**/*.spec.ts`, `!${args.src}/**/*.d.ts`]);
43
+ const sourceFiles = project.getSourceFiles().filter((f) => f.getFilePath() !== outFile);
44
+ // Pick the import extension the tsconfig expects: `.ts` (Node type stripping),
45
+ // `.js` (NodeNext) or none (Angular's `bundler` resolution).
46
+ const options = project.getCompilerOptions();
47
+ const importExt = options.allowImportingTsExtensions
48
+ ? '.ts'
49
+ : options.moduleResolution === ts.ModuleResolutionKind.Node16 ||
50
+ options.moduleResolution === ts.ModuleResolutionKind.NodeNext
51
+ ? '.js'
52
+ : '';
53
+ // resolve through the type checker so aliased/renamed imports still match
54
+ function resolveInterface(clause) {
55
+ let symbol = clause.getExpression().getSymbol();
56
+ if (symbol?.isAlias())
57
+ symbol = symbol.getAliasedSymbol();
58
+ return symbol?.getDeclarations().find(Node.isInterfaceDeclaration);
59
+ }
60
+ const keyOf = (decl) => `${decl.getSourceFile().getFilePath()}#${decl.getName()}`;
61
+ const registry = new Map();
62
+ // 1. interfaces that opt in by extending the marker directly
63
+ for (const sourceFile of sourceFiles) {
64
+ for (const iface of sourceFile.getInterfaces()) {
65
+ if (!iface.isExported())
66
+ continue;
67
+ if (!iface.getExtends().some((clause) => resolveInterface(clause)?.getName() === MARKER))
68
+ continue;
69
+ registry.set(keyOf(iface), { iface: { name: iface.getName(), file: sourceFile.getFilePath() }, impls: [] });
70
+ }
71
+ }
72
+ // 2. classes implementing one of those interfaces
73
+ for (const sourceFile of sourceFiles) {
74
+ for (const cls of sourceFile.getClasses()) {
75
+ const name = cls.getName();
76
+ if (!name || cls.isAbstract())
77
+ continue;
78
+ for (const clause of cls.getImplements()) {
79
+ const decl = resolveInterface(clause);
80
+ const entry = decl && registry.get(keyOf(decl));
81
+ if (!entry)
82
+ continue;
83
+ if (!cls.isExported() || cls.isDefaultExport()) {
84
+ console.warn(`skipped ${name} (${path.relative(process.cwd(), sourceFile.getFilePath())}): needs a named export`);
85
+ continue;
86
+ }
87
+ entry.impls.push({ name, file: sourceFile.getFilePath() });
88
+ }
89
+ }
90
+ }
91
+ function importPath(file) {
92
+ const rel = path.relative(outDir, file).split(path.sep).join('/').replace(/\.ts$/, importExt);
93
+ return rel.startsWith('.') ? rel : `./${rel}`;
94
+ }
95
+ // PaymentHandler -> PAYMENT_HANDLER_LIST
96
+ function tokenName(interfaceName) {
97
+ const snake = interfaceName.replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/([A-Z])([A-Z][a-z])/g, '$1_$2');
98
+ return `${snake.toUpperCase()}_LIST`;
99
+ }
100
+ const angular = args.target === 'angular';
101
+ const imports = angular ? [`import { InjectionToken, type Provider } from '@angular/core';`] : [];
102
+ const exports = [];
103
+ const providers = [];
104
+ const entries = [...registry.values()].sort((a, b) => a.iface.name.localeCompare(b.iface.name));
105
+ for (const { iface, impls } of entries) {
106
+ impls.sort((a, b) => a.name.localeCompare(b.name));
107
+ imports.push(`import type { ${iface.name} } from '${importPath(iface.file)}';`);
108
+ for (const impl of impls)
109
+ imports.push(`import { ${impl.name} } from '${importPath(impl.file)}';`);
110
+ // same shape as Angular's Type<T>, so it can be used directly as `useClass`
111
+ exports.push(`export const ${iface.name}Implementations: Array<new (...args: any[]) => ${iface.name}> = [${impls.map((i) => i.name).join(', ')}];`);
112
+ if (angular) {
113
+ const token = tokenName(iface.name);
114
+ exports.push(`export const ${token} = new InjectionToken<${iface.name}[]>('${token}');`);
115
+ // a multi token without any provider would throw on inject(), so fall back to []
116
+ if (impls.length === 0)
117
+ providers.push(` { provide: ${token}, useValue: [] },`);
118
+ for (const impl of impls)
119
+ providers.push(` { provide: ${token}, useClass: ${impl.name}, multi: true },`);
120
+ }
121
+ }
122
+ if (angular) {
123
+ exports.push('', 'export function provideImplscan(): Provider[] {', ' return [', ...providers, ' ];', '}');
124
+ }
125
+ const content = ['// GENERATED by ts-implscan – do not edit', ...imports, '', ...exports, ''].join('\n');
126
+ if (args['dry-run']) {
127
+ console.log(content);
128
+ }
129
+ else {
130
+ mkdirSync(outDir, { recursive: true });
131
+ writeFileSync(outFile, content);
132
+ for (const { iface, impls } of entries)
133
+ console.log(`${iface.name}: ${impls.map((i) => i.name).join(', ') || '(none)'}`);
134
+ console.log(`wrote ${path.relative(process.cwd(), outFile)}`);
135
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Marker interface: every interface that extends `ImplScan` gets its implementations
3
+ * collected by the `ts-implscan` CLI. It has no members and no runtime cost.
4
+ *
5
+ * @example
6
+ * export interface PaymentHandler extends ImplScan {
7
+ * pay(amount: number): string;
8
+ * }
9
+ */
10
+ export interface ImplScan {
11
+ }
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,68 @@
1
1
  {
2
2
  "name": "@thomasrepnik/ts-implscan",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
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.",
5
+ "keywords": [
6
+ "typescript",
7
+ "dependency-injection",
8
+ "angular",
9
+ "multi-provider",
10
+ "injection-token",
11
+ "code-generation",
12
+ "ts-morph",
13
+ "spring",
14
+ "component-scan"
15
+ ],
16
+ "homepage": "https://github.com/thomasrepnik/ts-implscan#readme",
17
+ "bugs": {
18
+ "url": "https://github.com/thomasrepnik/ts-implscan/issues"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/thomasrepnik/ts-implscan.git"
23
+ },
24
+ "license": "MIT",
25
+ "author": "Thomas Repnik",
26
+ "type": "module",
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "default": "./dist/index.js"
31
+ }
32
+ },
33
+ "types": "./dist/index.d.ts",
34
+ "bin": {
35
+ "ts-implscan": "dist/cli.js"
36
+ },
37
+ "files": [
38
+ "dist"
39
+ ],
40
+ "engines": {
41
+ "node": ">=22"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "scripts": {
47
+ "build": "tsc -p tsconfig.build.json",
48
+ "prepack": "npm run build",
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",
51
+ "generate:dry": "node src/cli.ts --src example --dry-run",
52
+ "generate:dry:angular": "node src/cli.ts --src example --target angular --dry-run",
53
+ "prestart": "npm run generate",
54
+ "start": "node example/main.ts",
55
+ "prestart:angular": "npm run generate",
56
+ "start:angular": "node example/main-angular.ts",
57
+ "test:package": "bash scripts/smoke-test.sh"
58
+ },
59
+ "dependencies": {
60
+ "ts-morph": "^28.0.0"
61
+ },
62
+ "devDependencies": {
63
+ "@angular/core": "^22.2.4",
64
+ "@types/node": "^26.6.6",
65
+ "rxjs": "^7.8.2",
66
+ "typescript": "^7.0.2"
67
+ }
68
+ }