Angular-specific analysis and transform rules for detecting and fixing anti-patterns, performance issues, and best practice violations in Angular applications.
This plugin provides 28 analysis rules and 25 transform rules (one per transformation type) for analyzing and modernizing Angular codebases, with a focus on:
pnpm add @angular-modernizer/plugin-angular
Rule ID: plugin-angular:rxjs-optimization
Severity: Warning
Detects inefficient RxJS operator usage across multiple anti-patterns with impact analysis and refactoring complexity estimation.
Consolidate multiple .pipe().pipe() chains into a single pipe operator.
Before:
this.data$ = this.http
.get('/api/data')
.pipe(map((data) => data.items))
.pipe(filter((items) => items.length > 0))
.pipe(tap((items) => console.log(items)));
After:
this.data$ = this.http.get('/api/data').pipe(
map((data) => data.items),
filter((items) => items.length > 0),
tap((items) => console.log(items)),
);
Impact: Low | Refactoring Complexity: Low
HTTP calls or computed observables subscribed multiple times without caching.
Before:
// In component
this.userData$ = this.http.get<User>('/api/user');
// In template - triggers 3 HTTP requests
<div>{{ (userData$ | async)?.name }}</div>
<div>{{ (userData$ | async)?.email }}</div>
<div>{{ (userData$ | async)?.role }}</div>
After:
this.userData$ = this.http
.get<User>('/api/user')
.pipe(shareReplay({ bufferSize: 1, refCount: true }));
// Now only 1 HTTP request for all subscriptions
Impact: High | Refactoring Complexity: Low
Consecutive identical operators that should be merged.
Before:
this.filtered$ = this.items$.pipe(
map((items) => items.filter((i) => i.active)),
map((items) => items.sort((a, b) => a.name.localeCompare(b.name))),
);
After:
this.filtered$ = this.items$.pipe(
map((items) =>
items.filter((i) => i.active).sort((a, b) => a.name.localeCompare(b.name)),
),
);
Impact: Low | Refactoring Complexity: Low
FormControl valueChanges without debounceTime() or throttleTime().
Before:
this.searchControl.valueChanges
.pipe(switchMap((term) => this.searchService.search(term)))
.subscribe((results) => (this.results = results));
After:
this.searchControl.valueChanges
.pipe(
debounceTime(300),
switchMap((term) => this.searchService.search(term)),
)
.subscribe((results) => (this.results = results));
Impact: High | Refactoring Complexity: Low
Business logic inside .subscribe() that should use pipe operators.
Before:
this.users$.subscribe((users) => {
const activeUsers = users.filter((u) => u.active);
const sortedUsers = activeUsers.sort((a, b) => a.name.localeCompare(b.name));
this.displayUsers = sortedUsers.slice(0, 10);
});
After:
this.displayUsers$ = this.users$.pipe(
map(users => users.filter(u => u.active)),
map(users => users.sort((a, b) => a.name.localeCompare(b.name))),
map(users => users.slice(0, 10))
);
// In template
<div *ngFor="let user of displayUsers$ | async">{{ user.name }}</div>
Impact: Medium | Refactoring Complexity: Medium
Nested observable subscriptions without flattening operators.
Before:
this.route.params.subscribe((params) => {
this.userService.getUser(params['id']).subscribe((user) => {
this.user = user;
});
});
After:
this.user$ = this.route.params.pipe(
switchMap(params => this.userService.getUser(params['id']))
);
// In template
<div>{{ (user$ | async)?.name }}</div>
Impact: High | Refactoring Complexity: Medium
HTTP observables using catchError that returns of(null), of([]), or EMPTY without re-throwing, preventing callers from distinguishing success from failure.
Impact: High | Refactoring Complexity: Medium
Using Subject instead of BehaviorSubject for state management.
Before:
export class StateService {
private state$ = new Subject<AppState>();
setState(state: AppState) {
this.state$.next(state);
}
getState() {
return this.state$.asObservable();
}
}
After:
export class StateService {
private state$ = new BehaviorSubject<AppState>(initialState);
setState(state: AppState) {
this.state$.next(state);
}
getState() {
return this.state$.asObservable();
}
}
Impact: High | Refactoring Complexity: Low
{
"rxjs-optimization": {
"maxPipeChains": 1, // Max allowed chained .pipe() calls
"maxSubscribeComplexity": 2, // Max statements in subscribe blocks
"requireDebounceOnUserInput": true // Enforce debounce on FormControl valueChanges
}
}
Rule ID: angular:performance-violation
Severity: Error
Detects missing subscription cleanup patterns in component subscriptions and complex template getters.
Missing takeUntil Before:
export class UserComponent implements OnInit {
ngOnInit() {
this.userService.currentUser$.subscribe((user) => {
this.user = user;
}); // Memory leak - survives component destruction
}
}
Missing takeUntil After:
export class UserComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
ngOnInit() {
this.userService.currentUser$
.pipe(takeUntil(this.destroy$))
.subscribe((user) => {
this.user = user;
});
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}
Rule ID: angular:missing-onpush-change-detection
Severity: Warning
Detects components with @Input() properties using default change detection.
Before:
@Component({
selector: 'app-user-card',
// Missing: changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserCardComponent {
@Input() user!: User;
}
After:
@Component({
selector: 'app-user-card',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserCardComponent {
@Input() user!: User;
}
Rule ID: angular:service-without-injectable
Severity: Error
Detects services missing @Injectable() decorator.
Rule ID: angular:component-without-selector
Severity: Error
Detects components missing selector property.
Rule ID: angular:directive-without-selector
Severity: Error
Detects directives missing selector property.
Rule ID: angular:pipe-without-name
Severity: Error
Detects pipes missing name property.
Rule ID: angular:async-pipe-misuse
Severity: Warning
Detects incorrect usage of the async pipe in templates. Two violation types:
double-async-subscription: observable used with | async in template AND manually subscribed in a lifecycle hookmultiple-async-subscriptions: same observable used with | async two or more times in the same templateRule ID: angular:unnecessary-change-detection
Severity: Warning
Detects components using Default change detection when OnPush would suffice, and components calling detectChanges() without using OnPush strategy.
Rule ID: angular:missing-return-type
Severity: Warning
Detects methods in Angular classes that are missing explicit return type annotations.
Before:
@Component({...})
export class UserComponent {
// Missing return type
getUserName() {
return this.user?.name || 'Unknown';
}
}
After:
@Component({...})
export class UserComponent {
// Explicit return type
getUserName(): string {
return this.user?.name || 'Unknown';
}
}
Rule ID: angular:too-many-inputs
Severity: Warning
Detects components with excessive @Input() properties (default threshold: 10).
Configuration:
{
"too-many-inputs": {
"maxInputs": 5
}
}
Rule ID: angular:lifecycle-hook-violation
Severity: Warning/Error
Detects improper lifecycle hook usage and missing cleanup. Also detects heavy ngOnInit with 5 or more service calls (warning), escalating to error at 8 or more.
Before:
@Component({...})
export class TimerComponent {
private intervalId: number;
ngOnInit() {
// Missing cleanup - interval continues after component destruction
this.intervalId = window.setInterval(() => {
console.log('tick');
}, 1000);
}
}
After:
@Component({...})
export class TimerComponent implements OnDestroy {
private destroy$ = new Subject<void>();
ngOnInit() {
interval(1000).pipe(
takeUntil(this.destroy$)
).subscribe(() => console.log('tick'));
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}
Rule ID: angular:template-complexity
Severity: Warning
Detects complex operations in templates that cause side effects on every change detection cycle. Also detects component getters with 3 or more non-trivial statements (complex-template-getter violation type).
Before:
@Component({
template: `
<div>{{ getExpensiveComputation() }}</div>
<button (click)="users.push(newUser)">Add User</button>
`,
})
export class UserListComponent {
getExpensiveComputation() {
return this.users.filter((u) => u.active).length * 42;
}
}
After:
@Component({
template: `
<div>{{ activeUserCount }}</div>
<button (click)="addUser()">Add User</button>
`,
})
export class UserListComponent {
activeUserCount = this.users.filter((u) => u.active).length;
addUser() {
this.users.push(this.newUser);
this.activeUserCount = this.users.filter((u) => u.active).length;
}
}
Rule ID: angular:immutable-input-violation
Severity: Error
Detects mutation of @Input properties that break OnPush change detection.
Before:
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserCardComponent {
@Input() user: User;
updateUser() {
this.user.name = 'New Name'; // Mutates input - breaks OnPush
}
}
After:
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserCardComponent {
@Input() user: User;
updateUser() {
this.userChange.emit({ ...this.user, name: 'New Name' });
}
@Output() userChange = new EventEmitter<User>();
}
Rule ID: angular:missing-trackby
Severity: Warning
Detects ngFor directives without trackBy functions.
Before:
@Component({
template: `
<li *ngFor="let user of users">{{ user.name }}</li>
`,
})
export class UserListComponent {
users: User[] = [];
}
After:
@Component({
template: `
<li *ngFor="let user of users; trackBy: trackByUserId">{{ user.name }}</li>
`,
})
export class UserListComponent {
users: User[] = [];
trackByUserId(index: number, user: User): string {
return user.id;
}
}
Rule ID: angular:presentational-component-violation
Severity: Warning
Detects presentational (dumb) components that have taken on responsibilities belonging to container components:
service-dependency: constructor or inject() service dependencysubscription-in-lifecycle: subscriptions without takeUntilDestroyedmissing-output: service call without a corresponding @Outputbusiness-logic: keyword prefix combined with cyclomatic complexity above 3complex-state-management: 3 or more non-Input array/Map/Set propertiesConfigurable dialog class detection via .angular-modernizer.json rules section.
Rule ID: angular:service-mutable-state
Severity: Warning
Detects @Injectable services that mutate public array, Map, or Set fields via push, splice, pop, sort, or reverse.
Rule ID: angular:promise-vs-observable-anti-pattern
Severity: Warning
Detects 5 anti-patterns:
.toPromise() callsdetectChanges() callsRule ID: angular:component-wrapper-instantiation
Severity: Warning
Detects direct new X() instantiation of wrapper or adapter classes that should be injected. Configurable class name patterns via .angular-modernizer.json.
Rule ID: angular:dto-direct-instantiation
Severity: Warning
Detects direct new X() instantiation of DTO, Response, or Result classes. Configurable via .angular-modernizer.json.
Rule ID: angular:magic-string-selector
Severity: Error (Warning for selectors read from a property)
Detects string-literal selector arguments passed to dynamic component loading methods. Target method patterns are configurable via .angular-modernizer.json.
Transformation Type: constructor-to-inject
Converts Angular constructor injection to the modern inject() function.
Before:
@Component({...})
export class UserComponent {
constructor(
private userService: UserService,
private router: Router,
@Inject(APP_CONFIG) private config: AppConfig
) {}
}
After:
@Component({...})
export class UserComponent {
private readonly userService = inject(UserService);
private readonly router = inject(Router);
private readonly config = inject(APP_CONFIG);
}
Features:
inject() calls@Inject() decorators with string tokensinject import from @angular/corereadonly when it is never reassigned (api MemberWriteFinder: a private field is checked in its class, a protected or public one also through its references in the project, so a subclass assigning it keeps it mutable; templates are not checked); readonly parameters stay readonly; option readonlyInjectFields: false turns it offTransformation Type: interface-extraction
Extracts inline type literals into named interfaces.
Before:
@Component({...})
export class UserFormComponent {
@Input() user: {
id: number;
name: string;
email: string;
profile: {
avatar: string;
bio?: string;
};
};
}
After:
interface UserProfile {
avatar: string;
bio?: string;
}
interface User {
id: number;
name: string;
email: string;
profile: UserProfile;
}
@Component({...})
export class UserFormComponent {
@Input() user: User;
}
Features:
Transformation Type: dependency-injection-migration
Modernizes legacy Angular dependency injection patterns including string tokens and ReflectiveInjector.
Before:
// String token injection
@Injectable()
export class ApiService {
constructor(@Inject('API_URL') private apiUrl: string) {}
}
// ReflectiveInjector usage
const injector = ReflectiveInjector.resolveAndCreate([
{ provide: 'API_CONFIG', useValue: config },
]);
After:
// Modern injection token
export const API_URL = new InjectionToken<string>('API_URL');
@Injectable()
export class ApiService {
private apiUrl = inject(API_URL);
}
// Modern injector usage
const injector = Injector.create([{ provide: API_CONFIG, useValue: config }]);
Transformation Type: constructor-injection-transform
Converts manual service instantiation (new Service()) to proper inject(Service) calls.
Before:
@Component({...})
export class UserComponent {
constructor() {
this.httpClient = new HttpClient();
this.userService = new UserService();
}
private httpClient: HttpClient;
private userService: UserService;
}
After:
@Component({...})
export class UserComponent {
private httpClient = inject(HttpClient);
private userService = inject(UserService);
}
Configuration:
{
"constructor-injection-transform": {
"enabled": true,
"serviceSuffixes": ["Service", "Repository", "Client", "Store"],
"handleComplexExpressions": true
}
}
Transformation Type: component-inputs-interface-extraction
Extracts component input properties into dedicated interfaces.
Before:
@Component({...})
export class UserCardComponent {
@Input() userId: number;
@Input() userName: string;
@Input() isActive: boolean;
@Input() avatarUrl?: string;
}
After:
interface UserCardInputs {
userId: number;
userName: string;
isActive: boolean;
avatarUrl?: string;
}
@Component({...})
export class UserCardComponent {
@Input() inputs: UserCardInputs;
}
Configuration:
{
"component-inputs-interface-extraction": {
"enabled": true,
"minInputsThreshold": 3,
"generateUniqueNames": true,
"addToSeparateFile": false
}
}
Transformation Type: service-injection-cleanup
Converts manual service instantiation in property initializers and constructors to inject() calls.
Before:
@Component({...})
export class DataComponent {
private http = new HttpClient();
private router: Router = new Router();
constructor() {
this.http = new HttpClient();
}
}
After:
@Component({...})
export class DataComponent {
private http = inject(HttpClient);
private router = inject(Router);
}
Configuration:
{
"service-injection-cleanup": {
"enabled": true,
"servicePatterns": ["Service$", "Client$", "Repository$"],
"handleComplexExpressions": true
}
}
The plugin also exposes the following transformation types via the transform-code MCP tool:
promise-service-to-observable - Converts Promise-based service methods to Observable equivalentspromise-component-to-reactive - Converts async component methods to reactive patternspromise-cleanup - Removes .toPromise() and related promise bridge codesequential-await-to-forkjoin - Converts independent sequential awaits to parallel forkJoin()missing-output-transform - Adds output() signals to presentational components that call services directlysubscription-transform - Converts raw .subscribe() in lifecycle hooks to takeUntilDestroyed() patternlibrary-extraction - Executes a 7-phase library extraction workflow (use after extract-libraries detection)static-class-to-functions - Converts static-only utility classes to module-level functions and constantsextract-static-from-stateful - Moves public static methods of a stateful class to module-level functions above itany-to-interface - Replaces any-typed parameters and properties with interfaces inferred from member accessestype-safety - Adds missing parameter, return and property types (implicit any becomes unknown)dto-object-literal - Rewrites new X() followed by property assignments into a typed object literalinterface-duplication - Consolidates interface families into a base interface plus branded type aliasesfacade-pattern - Extracts complex component logic into a facade serviceform-modernization - Modernizes reactive form codecontainer-presentational - Turns components with service dependencies into presentational components with inputs and outputscore-shared-modules - Organizes the application into Core and Shared modulesinheritance-to-composition - Replaces class inheritance with injected servicesservice-bag-transform - Splits a Service Bag into focused services (rule ID refactor:service-bag-split)Each transform rule acts only when its type is requested: transform-code passes transformationType (and the call's options) under the '@angular-modernizer/plugin-angular' key of the transform config.
# Start MCP server
pnpm run mcp:start
# Transform constructor injection
transform-code --filePath src/app/user.component.ts --transformation constructor-to-inject
# Extract interfaces
transform-code --filePath src/app/data.models.ts --transformation interface-extraction
# Migrate DI patterns
transform-code --filePath src/app/legacy.service.ts --transformation dependency-injection-migration
# Convert subscriptions to takeUntilDestroyed
transform-code --filePath src/app/user.component.ts --transformation subscription-transform
# Parallelize sequential awaits
transform-code --filePath src/app/data.service.ts --transformation sequential-await-to-forkjoin
import { Kernel } from '@angular-modernizer/core';
import { createPublicApi } from '@angular-modernizer/api';
import { AngularPlugin } from '@angular-modernizer/plugin-angular';
import { ContextFactory } from '@angular-modernizer/plugin-system';
const plugin = new AngularPlugin();
const kernel = new Kernel({ plugins: [plugin] });
await kernel.initialize();
// Get specific transformation rule
const constructorToInjectRule = plugin
.getTransformRules()
.find((rule) => rule.id === 'angular:constructor-to-inject')!;
// Transform a file; the rule acts only when its transformation type is set
const project = kernel.getProject();
const sourceFile = project.addSourceFileAtPath('user.component.ts');
const context = ContextFactory.createTransformContext({
sourceFile,
project,
api: createPublicApi(project),
config: {
'@angular-modernizer/plugin-angular': {
transformationType: 'constructor-to-inject',
},
},
});
const result = await constructorToInjectRule.transform(context);
if (result.modified) {
await sourceFile.save();
}
# Run all plugin tests (analysis + transformation)
pnpm test
# Run transformation rule tests specifically
pnpm test transform-rules/
Tested on production Angular codebases:
| Metric | Analysis | Transformation |
|---|---|---|
| Files Processed | 2,753 | 500+ |
| Execution Time | ~3.2s | ~1.5s |
| Memory Usage | ~185 MB | ~95 MB |
MIT
Angular-specific analysis and transform rules for the Angular Modernizer kernel.
The main entry point is AngularPlugin: register it with the kernel to get 28 analysis rules and 25 transform rules. The rule classes and their orchestrators are also exported for direct use, for example MissingOnPushRule, RxJSOptimizationRule, TypeSafetyAnalysisRule, ServiceBagTransformRule with ServiceBagTransformOrchestrator, and LibraryExtractionTransformRule with LibraryExtractionOrchestrator.
Transform rules follow the "thin rule, thick orchestrator" pattern: the rule checks that its
transformationTypewas requested in the plugin config (key@angular-modernizer/plugin-angular) and delegates the work to an orchestrator class.