The open/closed principle for TypeScript, without the boilerplate.
Mark an interface, and ts-implscan finds every class that implements it at build time, then generates a typed registry, a dispatcher that picks the right one, and Angular providers.
- Installation
- The three markers
- Collect all implementations
- Dispatch to one implementation
- Good to know
- Limits
- Why
- Refactoring with an AI agent
- CLI options
- Development
npm install --save-dev @thomasrepnik/ts-implscanThe package contains two things:
| What | How you use it |
|---|---|
The marker interfaces (ImplScan, Dispatchable, DispatchContextProvider) |
import type { ImplScan } from '@thomasrepnik/ts-implscan' |
The ts-implscan CLI (the generator) |
npx ts-implscan, or in a package.json script |
It's a dev dependency because it only runs at build time. The marker imports are type-only and disappear when compiled, so nothing from this package ends up in your production bundle. You need Node 22 or newer.
| Your interface or class | What the generator produces |
|---|---|
interface X extends ImplScan |
XImplementations array; Angular: X_LIST token and multi providers |
interface X extends Dispatchable<Ctx> |
the above, plus XDispatcher and (Angular) the X_CONTEXT token |
interface X extends Dispatchable |
list, X_LIST and XDispatcher, but no context and no X_CONTEXT token |
class P implements DispatchContextProvider<Ctx> |
Angular: provideImplscan() builds X_CONTEXT from P for every X using Ctx |
Everything lands in one generated file, and in Angular one provideImplscan() registers it all.
This is the equivalent of Spring's List<PaymentHandler>.
// payment-handler.ts
import type { ImplScan } from '@thomasrepnik/ts-implscan';
export interface PaymentHandler extends ImplScan {
name: string;
pay(amount: number): string;
}Write implementations as usual, anywhere under src/. They must be exported:
// card-handler.ts
export class CardHandler implements PaymentHandler {
name = 'card';
pay(amount: number) {
return `charged ${amount} to credit card`;
}
}
// paypal-handler.ts
export class PaypalHandler implements PaymentHandler { ... }Add the generator to your scripts, so it runs before every ng serve and ng build:
"scripts": {
"generate": "ts-implscan --target angular --project tsconfig.app.json --out src/app/generated/implscan-registry.ts",
"prestart": "npm run generate",
"prebuild": "npm run generate"
}Register all implementations once, then inject the list anywhere:
// app.config.ts
import { provideImplscan } from './generated/implscan-registry';
export const appConfig: ApplicationConfig = {
providers: [provideImplscan()],
};
// payment.service.ts
import { PAYMENT_HANDLER_LIST } from './generated/implscan-registry';
@Injectable({ providedIn: 'root' })
export class PaymentService {
private readonly handlers = inject(PAYMENT_HANDLER_LIST); // PaymentHandler[]
payWithAll(amount: number) {
return this.handlers.map((handler) => handler.pay(amount));
}
}PaymentService above is an ordinary consumer of the list, so providedIn: 'root' is fine there.
The implementations are different: provideImplscan() already creates one instance of each,
so an implementation must not be root-provided as well, or it would exist twice. If an
implementation needs DI itself, mark it with a bare @Injectable():
// twint-handler.ts
@Injectable() // no providedIn: 'root'
export class TwintHandler implements PaymentHandler {
private readonly http = inject(HttpClient);
name = 'twint';
pay(amount: number) { ... }
}What gets generated
// src/app/generated/implscan-registry.ts
// GENERATED by ts-implscan – do not edit
import { InjectionToken, type Provider } from '@angular/core';
import type { PaymentHandler } from '../payment/payment-handler';
import { CardHandler } from '../payment/card-handler';
import { PaypalHandler } from '../payment/paypal-handler';
export const PaymentHandlerImplementations: Array<new (...args: any[]) => PaymentHandler> = [CardHandler, PaypalHandler];
export const PAYMENT_HANDLER_LIST = new InjectionToken<PaymentHandler[]>('PAYMENT_HANDLER_LIST');
export function provideImplscan(): Provider[] {
return [
{ provide: PAYMENT_HANDLER_LIST, useClass: CardHandler, multi: true },
{ provide: PAYMENT_HANDLER_LIST, useClass: PaypalHandler, multi: true },
];
}There is one token per marked interface, and provideImplscan() registers all of them.
The default target generates plain arrays of classes, with no framework dependency. It works for Node, other frameworks, or plain TypeScript:
"scripts": {
"generate": "ts-implscan",
"prestart": "npm run generate",
"prebuild": "npm run generate"
}import { PaymentHandlerImplementations } from './generated/implscan-registry';
const handlers = PaymentHandlerImplementations.map((Handler) => new Handler()); // PaymentHandler[]
for (const handler of handlers) {
console.log(handler.pay(42));
}If your implementations have dependencies, register the classes from the array with your DI
container instead of calling new.
What gets generated
// src/generated/implscan-registry.ts
// GENERATED by ts-implscan – do not edit
import type { PaymentHandler } from '../payment/payment-handler';
import { CardHandler } from '../payment/card-handler';
import { PaypalHandler } from '../payment/paypal-handler';
export const PaymentHandlerImplementations: Array<new (...args: any[]) => PaymentHandler> = [CardHandler, PaypalHandler];That's it. A new implementation is picked up the next time the generator runs. A new
interface only needs extends ImplScan, and with Angular its token is generated too. The
example in this repository runs both variants (npm start, npm run start:angular).
Often you don't want all implementations but the right one: the payment handler for the selected method, the exporter for the chosen format. Instead of injecting the list and choosing by hand, let the generator write the dispatcher.
Replace ImplScan with Dispatchable<TContext>, where TContext describes what the choice
depends on:
// payment-context.ts
export interface PaymentContext {
method: 'card' | 'paypal' | 'twint';
}
// payment-handler.ts
import type { Dispatchable } from '@thomasrepnik/ts-implscan';
export interface PaymentHandler extends Dispatchable<PaymentContext> {
name: string;
pay(amount: number): string;
}Dispatchable extends ImplScan, so the list and its token are still generated.
// card-handler.ts
export class CardHandler implements PaymentHandler {
name = 'card';
isApplicableTo(context: PaymentContext) {
return context.method === 'card';
}
getPriority() {
return 10; // higher wins when several handlers apply
}
pay(amount: number) {
return `charged ${amount} to credit card`;
}
}getPriority() is optional and defaults to 0. When the isApplicableTo conditions never
overlap, leave it out.
The generator knows the context type, but not where its values live. Pick one of three ways.
A context provider class next to the interface (recommended). It implements
DispatchContextProvider<PaymentContext>, the scan finds it like an implementation, and in
Angular provideImplscan() builds the PAYMENT_HANDLER_CONTEXT token from it. Nothing is added
to app.config.ts. get() runs on every dispatcher call, so it may read a signal:
// payment-context.provider.ts
import type { DispatchContextProvider } from '@thomasrepnik/ts-implscan';
export class PaymentContextProvider implements DispatchContextProvider<PaymentContext> {
private readonly settings = inject(PaymentSettings);
get(): PaymentContext {
return { method: this.settings.method() };
}
}One provider serves every interface that uses the same context type.
No context at all. Extend Dispatchable without a type argument. Then isApplicableTo()
takes no parameter, no token is generated, and each implementation decides from what it injects:
export interface ReceiptFormatter extends Dispatchable {
format(lines: string[]): string;
}
export class MarkdownFormatter implements ReceiptFormatter {
private readonly settings = inject(ReceiptSettings);
isApplicableTo() {
return this.settings.format() === 'md';
}
getPriority() {
return 10;
}
format(lines: string[]) {
return lines.map((line) => `- ${line}`).join('\n');
}
}The token by hand. Provide PAYMENT_HANDLER_CONTEXT with a () => PaymentContext (a
Signal qualifies). This overrides a provider class, which is useful in tests or for a component
or route with its own context. Provide the dispatcher class in the same providers, so that it
sees the local token:
providers: [PaymentHandlerDispatcher, { provide: PAYMENT_HANDLER_CONTEXT, useValue: () => ({ method: 'card' }) }],Angular: provideImplscan() registers it, inject it like any service.
private readonly payment = inject(PaymentHandlerDispatcher);
this.payment.pay(42); // forwarded to CardHandler, PaypalHandler or TwintHandlerWithout Angular: the plain dispatcher takes the implementations and, for a typed context, the context function in its constructor.
let method: PaymentContext['method'] = 'twint';
const payment = new PaymentHandlerDispatcher(handlers, () => ({ method }));
payment.pay(42); // TwintHandler
method = 'card';
payment.pay(42); // CardHandler
const receipt = new ReceiptFormatterDispatcher(formatters); // context-free: no second argumentOn every call, the dispatcher reads the context (if the interface has one) and filters the
implementations with isApplicableTo(context):
- none applicable: throws, naming the context and all candidates with their priorities
- exactly one: used, whatever its priority
- several: the highest
getPriority()wins (a missinggetPrioritycounts as0) - a tie for the highest priority: throws, naming the tied candidates
Because the choice is made per call, a context that changes between calls switches the
implementation, and nothing is resolved while the injector is still being built. Two things
follow: keep isApplicableTo cheap and free of side effects, and remember that with a changing
context a stateful setter and a later call may land on different implementations.
What gets generated
export const PAYMENT_HANDLER_CONTEXT = new InjectionToken<() => PaymentContext>('PAYMENT_HANDLER_CONTEXT');
export class PaymentHandlerDispatcher implements PaymentHandler {
private readonly implementations = inject(PAYMENT_HANDLER_LIST);
private readonly context = inject(PAYMENT_HANDLER_CONTEXT);
private get delegate(): PaymentHandler {
return resolveDelegate('PaymentHandlerDispatcher', this.implementations, this.context());
}
isApplicableTo(context: PaymentContext): boolean {
return this.delegate.isApplicableTo(context);
}
getPriority(): number {
return this.delegate.getPriority();
}
get name(): string {
return this.delegate.name;
}
set name(value: string) {
this.delegate.name = value;
}
pay(amount: number): string {
return this.delegate.pay(amount);
}
}
// inside provideImplscan()
{ provide: PaymentContextProvider, useClass: PaymentContextProvider },
{ provide: PAYMENT_HANDLER_CONTEXT, useFactory: () => { const provider = inject(PaymentContextProvider); return () => provider.get(); } },
{ provide: PaymentHandlerDispatcher, useClass: PaymentHandlerDispatcher },Signatures are copied from the interface, and the types they use are imported into the
generated file. resolveDelegate is emitted once per generated file. A context-free dispatcher
has no context field and no token. The plain target has the same class with a constructor
instead of inject().
Lists:
- Check what would be collected without writing anything:
npx ts-implscan --dry-run. - Replace a whole list in Angular tests with one provider:
providers: [{ provide: PAYMENT_HANDLER_LIST, useValue: [fakeHandler] }]. - A marked interface without implementations gives an empty array, and in Angular a
useValue: []provider, so injecting it doesn't throw.
Dispatchers:
- A dispatcher over an empty list throws on its first call, not when it is created.
- The Angular dispatcher comes from DI, through
provideImplscan()or by re-providing the class. Creating it withnewfails, because its fields useinject(). - Interfaces the generator can't forward (see Limits) print a warning and still get their list and token, only the dispatcher is skipped.
Workflow:
- Commit the generated file, so the IDE and type checks work right after a checkout. Let CI
catch a forgotten regeneration with
npm run generate && git diff --exit-code. - In CI,
prebuildonly runs throughnpm run build. If your pipeline callsng builddirectly, runnpm run generatefirst.
Collecting:
- Only interfaces that extend
ImplScanorDispatchabledirectly are collected. The CLI detects the markers by name, so a localinterface ImplScan {}works too; a localDispatchablemust declare the same two members. - Only explicit
implements Xclauses count. Classes that only match the interface structurally, and subclasses of an implementing class, are not found. - Implementations and context providers must be exported, non-abstract, named-export classes. A non-exported class is skipped with a warning.
Dispatching:
- A
Dispatchableinterface and its base types must not declareisApplicableToorgetPrioritythemselves, whatever the signature: the dispatcher creates them automatically, so the generation stops with an error. Implementation classes still implement both. - Some interface shapes get a warning and no dispatcher: overloaded or optional members,
destructured parameters, accessors,
thisreturn types, index or call signatures, generic interfaces, members inherited from a generic base other thanDispatchable, signatures using non-exported types, and two imported types sharing one name. - A dispatcher forwards property writes through a setter;
readonlyproperties get no setter. - A
DispatchContextProvideris matched to interfaces by its context type, which must be a named interface or type alias. Inline object types never match, and two providers for one type produce a warning and no wiring. isApplicableToruns on every dispatcher call; keep it cheap.
In Spring you can inject List<PaymentHandler>, and the container hands you every bean that
implements the interface. TypeScript can't do this out of the box, for two reasons:
- Interfaces are erased. At runtime nothing records that a class implements
PaymentHandler. - There is no classpath scanning. JavaScript only runs files that something imports, so a new implementation that nobody imports doesn't exist at runtime.
DI containers such as Angular, tsyringe or InversifyJS solve this with injection tokens, but then you have to register every implementation by hand, and it's easy to forget one. Choosing one implementation adds a second piece of boilerplate: a hand-written dispatcher per interface that filters the list and forwards every method.
ts-implscan moves both to build time. It uses ts-morph and the
TypeScript type checker to find every exported class that implements a marked interface, and
copies the interface's signatures into the dispatcher. The generated file is ordinary TypeScript,
so tsc fails if a listed class doesn't actually satisfy its interface.
Why a marker interface rather than a comment tag like /** @implscan */? The marker is
checked by the compiler, so a typo doesn't silently drop the interface. Your IDE's "Find usages"
on ImplScan lists every collected interface, and renames are safe. It's the same idea as a Java
marker interface such as Serializable, and it keeps out the interfaces you implement for other
reasons, such as Angular's OnInit.
- Plugin and strategy patterns, such as payment providers, exporters, validators, notification channels or feature handlers, where the set of implementations keeps growing.
- Teams coming from Spring or Java who want the open/closed principle that
List<PaymentHandler>injection gives them: adding behaviour means adding a class, not editing a central list or a dispatcher. - Angular apps with many multi-provider tokens, so nobody forgets to register a new implementation, or to write the token.
It's probably overkill if you have two implementations that rarely change. A hand-written array is fine then.
The package includes an agent skill that gives every refactor
the same shape: file layout, context provider, priorities and where provideImplscan() goes. Copy it
to where your agent looks for skills, for Claude Code:
mkdir -p .claude/skills
cp -R node_modules/@thomasrepnik/ts-implscan/skills/ts-implscan-refactor .claude/skills/The skill only covers the shape. evals/refactor-skill has the
scenarios it was tested on and the results.
ts-implscan [--project tsconfig.json] [--src src] [--out src/generated/implscan-registry.ts] [--target plain|angular] [--dry-run]| Option | Default | Meaning |
|---|---|---|
--project |
tsconfig.json |
tsconfig to read compiler options from (paths, resolution) |
--src |
src |
Folder to scan. *.spec.ts, *.d.ts and previously generated files are skipped. |
--out |
src/generated/implscan-registry.ts |
File to generate |
--target |
plain |
angular adds the InjectionTokens and provideImplscan() |
--dry-run |
off | Print the result to stdout instead of writing it |
-h, --help |
Show the help |
The files to scan come from --src, not from the tsconfig, because a new implementation isn't
imported anywhere yet. The tsconfig is only read for compiler options. The import extension in
the generated file follows it too: .ts with allowImportingTsExtensions, .js with NodeNext,
and no extension with bundler, which is what Angular uses.
This repository contains the library in src/ and a runnable demo in example/. The demo has
three marked interfaces: PaymentHandler (Dispatchable<PaymentContext> with a scanned context
provider), ReceiptFormatter (context-free Dispatchable, try RECEIPT_FORMAT=md npm start) and
Notifier (ImplScan, a plain list).
npm install
npm run generate:dry # print the plain registry for the example, write nothing
npm run generate:dry:angular # the same with Angular tokens and providers
npm start # regenerate, then run the example with plain arrays
npm run start:angular # regenerate, then resolve everything through Angular's real Injector
npm run typecheck
npm test # generator tests (in-memory ts-morph projects), CLI tests and the generated dispatcher
npm run test:package # pack, install into an empty project, run the CLI from there@angular/core and rxjs are dev dependencies only so that start:angular can run Angular's
injector in Node. The published package only depends on ts-morph.
Releases are uploaded to npm by .github/workflows/release.yml, using npm
trusted publishing. It doesn't need an npm token.
npm version minor # or patch / major: bumps package.json, commits and tags vX.Y.Z
git push --follow-tags
gh release create v0.2.0 --generate-notes # or click "Publish release" on GitHubPublishing the GitHub release starts the workflow. It checks that the tag matches
package.json, type-checks, runs the package smoke test and stages the version on npm.
A staged version isn't public until a maintainer approves it with 2FA:
npm stage list @thomasrepnik/ts-implscan # shows the stage id
npm stage approve <stage-id>