Skip to content

Repository files navigation

ts-implscan

npm CI

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

npm install --save-dev @thomasrepnik/ts-implscan

The 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.

The three markers

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.

Collect all implementations

This is the equivalent of Spring's List<PaymentHandler>.

1. Mark an interface

// payment-handler.ts
import type { ImplScan } from '@thomasrepnik/ts-implscan';

export interface PaymentHandler extends ImplScan {
  name: string;
  pay(amount: number): string;
}

2. Implement it

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 { ... }

3. Generate the registry and use it

Angular

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.

Without Angular

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).

Dispatch to one implementation

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.

1. Mark the interface as dispatchable

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.

2. Let every implementation say when it applies

// 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.

3. Supply the context

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' }) }],

4. Use the dispatcher

Angular: provideImplscan() registers it, inject it like any service.

private readonly payment = inject(PaymentHandlerDispatcher);
this.payment.pay(42); // forwarded to CardHandler, PaypalHandler or TwintHandler

Without 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 argument

How the delegate is chosen

On 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 missing getPriority counts as 0)
  • 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().

Good to know

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 with new fails, because its fields use inject().
  • 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, prebuild only runs through npm run build. If your pipeline calls ng build directly, run npm run generate first.

Limits

Collecting:

  • Only interfaces that extend ImplScan or Dispatchable directly are collected. The CLI detects the markers by name, so a local interface ImplScan {} works too; a local Dispatchable must declare the same two members.
  • Only explicit implements X clauses 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 Dispatchable interface and its base types must not declare isApplicableTo or getPriority themselves, 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, this return types, index or call signatures, generic interfaces, members inherited from a generic base other than Dispatchable, signatures using non-exported types, and two imported types sharing one name.
  • A dispatcher forwards property writes through a setter; readonly properties get no setter.
  • A DispatchContextProvider is 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.
  • isApplicableTo runs on every dispatcher call; keep it cheap.

Why

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:

  1. Interfaces are erased. At runtime nothing records that a class implements PaymentHandler.
  2. 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.

When it's helpful

  • 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.

Refactoring with an AI agent

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.

CLI options

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.

Development

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.

Releasing

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 GitHub

Publishing 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>

License

MIT

Releases

Packages

Contributors

Languages