Skip to content

Latest commit

 

History

History
1971 lines (1539 loc) · 79.3 KB

File metadata and controls

1971 lines (1539 loc) · 79.3 KB
description Migrate AngularJS 1.x to Angular 18: incremental ngUpgrade hybrid as the default path, with standalone-default components, signals, control flow (@if/@for/@switch), deferrable views, function-based interceptors and guards, provideHttpClient + provideRouter, inject(), typed reactive forms, and Vitest as the Angular 18 target. AngularJS 1.x is EOL since Dec 31 2021 with unpatched CVE-2024-21490 (ReDoS in ng-srcset). Catches 62 LLM regressions in three modes: (A) AngularJS 1.x patterns emitted for plain Angular requests, (B) Angular 2-17 legacy idioms emitted instead of Angular 18, (C) migration-specific mistakes (big-bang advice without ngUpgrade, literal $rootScope.$broadcast ports, missing downgradeInjectable, wrong bootstrap order). Pair with HeroDevs NES or OpenLogic if a freeze prevents migration.
applyTo **/*.ts, **/*.js, **/*.html, **/*.scss, **/*.css, angular.json, package.json, **/*.spec.ts

AngularJS to Angular 18 Migration

Context: why this matters

AngularJS 1.x exited Long-Term Support on December 31, 2021. From January 1, 2022 Google stopped shipping security fixes, browser-compatibility fixes, and jQuery-compatibility fixes. A README-only release (v1.8.3, "ultimate-farewell", April 7, 2022) is the final official version; the substantive code freeze is v1.8.2 (October 2020). Every CVE filed against AngularJS after January 2022 is, by definition, unpatched upstream. The most-cited live vulnerability is CVE-2024-21490, a Regular Expression Denial of Service in the ng-srcset directive affecting every AngularJS release from 1.3.0 through 1.8.3; the only fixed builds are the commercial HeroDevs NES forks (1.5.19, 1.9.3 and later). Source: HeroDevs CVE-2024-21490 advisory, endoflife.date / AngularJS.

Two named vendors sell commercial extended support in 2026: HeroDevs Never-Ending Support (originally XLTS.dev, merged September 2023; public customers include Google, Microsoft, GE, Capital One; listed on Azure Marketplace) and OpenLogic by Perforce. The US Department of Homeland Security Customs and Border Protection awarded HeroDevs an AngularJS support task order, which is the cleanest public proof that regulated federal infrastructure is paying commercial rates to not migrate. There is no free or community-supported AngularJS LTS. The only economically rational options for a regulated org are: (a) pay HeroDevs or OpenLogic, or (b) migrate. Source: HeroDevs AngularJS NES, OpenLogic AngularJS Long-Term Support.

LLM-assisted migration is where this instructions file earns its keep. HeroDevs reports that 73% of AI-assisted AngularJS migrations fall behind schedule, citing the gap between training corpora (largely Angular 12 and earlier) and modern Angular 18 patterns. GitHub Copilot's own public issue tracker corroborates the mechanism: vscode-copilot-release#1019 and vscode-copilot-release#1128 track "Copilot does not know about Angular 17" complaints. The Angular team ships llms.txt and llms-full.txt on angular.dev as official AI-context hints because the problem is systemic. This file pins the Angular 18 patterns the model must emit and the 62 anti-patterns it must refuse. Source: HeroDevs: Why 73% of AI-assisted AngularJS migrations fall behind.

Migration target: Angular 18 canonical patterns

Angular 18 shipped on May 22, 2024. The major stabilised signals, control flow (@if / @for / @switch), and deferrable views (@defer); deprecated HttpClientModule and class-based interceptors; and shipped zoneless change detection as a developer preview. The migration target rule of thumb: write everything as if you were starting on Angular 19, accepting that a few defaults (standalone: true, NgModule deprecation) only became the default in v19 but were the recommended pattern in v18. Source: Angular v18 announcement.

Standalone components default

A standalone component does not need an NgModule. It imports its own dependencies in imports: [] and is bootstrapped via bootstrapApplication from @angular/platform-browser. The first-party schematic ng g @angular/core:standalone runs the conversion in three idempotent passes (components, modules, bootstrap).

import { Component, signal } from '@angular/core';
import { UserCardComponent } from './user-card.component';

@Component({
  selector: 'app-user-list',
  standalone: true,
  imports: [UserCardComponent],
  template: `
    @for (user of users(); track user.id) {
      <app-user-card [user]="user" />
    }
  `,
})
export class UserListComponent {
  users = signal<User[]>([]);
}
// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, { providers: [/* … */] });

Source: Angular: Standalone migration, The future is standalone.

Signals (signal, computed, effect)

Signals shipped to developer preview in Angular 16 (May 2023), stabilised core APIs in v17.2, and became fully stable in v18 for the component-input / output / model / view-query surface. The Angular team recommends signals as the default reactivity primitive; RxJS stays for streams.

import { signal, computed, effect } from '@angular/core';

const count = signal(0);
const double = computed(() => count() * 2);
effect(() => console.log(count()));

count.set(5);
count.update(n => n + 1);

A signal is read like a function (count()) and written through .set / .update. computed() is read-only and recomputes only when its dependencies change. effect() re-runs when any signal it reads changes. Source: Angular: Signals.

Signal-based component I/O (input, output, model)

Decorator-based @Input() and @Output() still compile in Angular 18 but the canonical pattern is signal-based. Signal inputs automatically mark their owning OnPush component as dirty when they change.

import { Component, input, output, model } from '@angular/core';

@Component({
  selector: 'user-card',
  standalone: true,
  template: `
    <h3>{{ user().name }}</h3>
    <button (click)="remove.emit(user().id)">Remove</button>
    <input [(ngModel)]="draft" />
  `,
})
export class UserCardComponent {
  user = input.required<User>();
  remove = output<string>();
  draft = model('');
}

input.required<T>() marks the input required at the template level. output() returns an emitter without an EventEmitter allocation. model() is a writable signal that supports [(prop)] two-way binding. Source: v18 docs: Inputs as signals, Angular University: Signal Components.

Built-in control flow (@if / @for / @switch)

Stabilised in v18. The @for syntax requires an explicit track expression; the compiler warns if it is missing because rebuilding without a track key is the historical *ngFor-without-trackBy performance bug. A first-party schematic auto-converts existing *ngIf / *ngFor / *ngSwitch templates.

@if (user(); as u) {
  <p>{{ u.name }}</p>
} @else if (loading()) {
  <p>Loading...</p>
} @else {
  <p>No user.</p>
}

@for (item of items(); track item.id; let i = $index, isFirst = $first) {
  <li>{{ i }}: {{ item.name }}</li>
} @empty {
  <li>No items.</li>
}

@switch (status()) {
  @case ('idle')    { <p>Idle</p> }
  @case ('loading') { <p>Working</p> }
  @default          { <p>Done</p> }
}

@for block locals: $index, $first, $last, $even, $odd, $count. Source: HeroDevs: Control-flow migration schematic.

Deferrable views (@defer)

@defer declaratively defers loading of a template subtree and its dependencies until a trigger fires, with @placeholder, @loading, and @error sub-blocks. Every dependency referenced inside a @defer block must be standalone; non-standalone declarations eagerly load anyway, defeating the purpose. Bill.com publicly reported a 50% bundle-size reduction in one app using @defer, cited in the v18 announcement.

@defer (on viewport; prefetch on idle) {
  <heavy-chart [data]="data()" />
} @placeholder {
  <div class="skeleton"></div>
} @loading (after 100ms; minimum 1s) {
  <spinner />
} @error {
  <p>Failed to load chart.</p>
}

Triggers: on idle (default), on viewport, on interaction, on hover, on immediate, on timer(2s), when expr(). Source: Angular: Deferred loading with @defer.

HttpClient via provideHttpClient and functional interceptors

HttpClientModule and HttpClientTestingModule are deprecated in Angular 18. The canonical setup uses provideHttpClient(withInterceptors([...])) in the bootstrap providers. Interceptors are plain functions of type HttpInterceptorFn.

// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { authInterceptor } from './app/interceptors/auth.interceptor';
import { AppComponent } from './app/app.component';

bootstrapApplication(AppComponent, {
  providers: [provideHttpClient(withInterceptors([authInterceptor]))],
});
// auth.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthService } from './auth.service';

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const token = inject(AuthService).token();
  const authReq = token
    ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })
    : req;
  return next(authReq);
};

Functional interceptors have predictable execution order (the order in the withInterceptors([...]) array), no class-construction ambiguity, and use inject() for DI. Class-based interceptors still work via provideHttpClient(withInterceptorsFromDi()) as a migration bridge; new code should not use them. Source: Angular: Intercepting requests and responses, angular/angular#56964.

inject() over constructor DI

The inject() function has been available since Angular 14 but became the recommended default in v18 alongside standalone components and signal inputs. It works inside standalone functions (interceptors, guards, resolvers, factories) where constructors cannot, gives more accurate types, and supports conditional / lazy injection.

import { Component, inject } from '@angular/core';
import { UserService } from './user.service';

@Component({ /* … */ })
export class UserListComponent {
  private readonly users = inject(UserService);
}

Constraint: inject() can only be called in an injection context (constructor, field initialiser, factory function). Calling it from ngOnInit or a click handler throws NG0203. A first-party migration schematic converts class-constructor patterns to inject() calls. Source: Angular: inject() function migration.

Routing via provideRouter and withComponentInputBinding

Standalone router setup replaces RouterModule.forRoot(routes) with provideRouter(routes, ...features). withComponentInputBinding() flows route path params, matrix params, query params, route data, and resolver output into the component as inputs; with signal inputs you get a reactive value out of the box.

import { provideRouter, withComponentInputBinding, withViewTransitions, Routes } from '@angular/router';

const routes: Routes = [
  {
    path: 'users/:id',
    loadComponent: () => import('./user/user.component').then(m => m.UserComponent),
    canActivate: [authGuard],
  },
];

bootstrapApplication(AppComponent, {
  providers: [provideRouter(routes, withComponentInputBinding(), withViewTransitions())],
});
@Component({ /* … */ })
export class UserComponent {
  id = input.required<string>(); // bound from /users/:id automatically
}

Precedence when multiple sources have the same key: resolvers, then data, then query params, then matrix, then path. Source: Angular: withComponentInputBinding.

Function-based guards and resolvers

Class-based CanActivate / CanActivateChild / CanDeactivate / CanMatch / CanLoad guards are deprecated as of Angular 15.2. Replace with typed functions.

import { CanActivateFn, ResolveFn, Router } from '@angular/router';
import { inject } from '@angular/core';
import { AuthService } from './auth.service';
import { UserService } from './user.service';

export const authGuard: CanActivateFn = (route, state) => {
  const auth = inject(AuthService);
  const router = inject(Router);
  return auth.isLoggedIn() ? true : router.parseUrl('/login');
};

export const userResolver: ResolveFn<User> = (route) =>
  inject(UserService).get(route.params['id']);

CanActivateFn, CanMatchFn, CanDeactivateFn, ResolveFn are all exported from @angular/router. Source: angular/angular#50234.

Typed reactive forms (FormGroup)

Angular 14 introduced strictly typed reactive forms; v18 leaves the contract stable. form.value is Partial<T> (disabled controls excluded); form.getRawValue() is the full T. fb.nonNullable.group(...) creates controls that never emit null after reset().

import { FormBuilder, FormGroup, FormControl, Validators } from '@angular/forms';

interface LoginForm {
  email: FormControl<string>;
  password: FormControl<string>;
  remember?: FormControl<boolean>;
}

@Component({ /* … */ })
export class LoginComponent {
  private fb = inject(FormBuilder);
  form: FormGroup<LoginForm> = this.fb.nonNullable.group({
    email: ['', [Validators.required, Validators.email]],
    password: ['', Validators.required],
  });

  submit() {
    if (this.form.valid) {
      const value = this.form.getRawValue(); // { email: string; password: string }
    }
  }
}

Source: Angular: Strictly typed reactive forms.

OnPush by default and zoneless dev preview

The recommendation in Angular 18 is changeDetection: ChangeDetectionStrategy.OnPush on every component. The RFC to make OnPush the default and deprecate ChangeDetectionStrategy.Default is open as angular/angular#66779. Signal writes automatically mark the consuming OnPush component for check, so the old "I changed a field but the view didn't update" trap no longer applies to signal-driven components.

import { ChangeDetectionStrategy, Component, input } from '@angular/core';

@Component({
  selector: 'user-card',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<h3>{{ user().name }}</h3>`,
})
export class UserCardComponent {
  user = input.required<User>();
}

Zoneless change detection landed in v18 as a developer-preview opt-in via provideZonelessChangeDetection() and stabilised in v20. For an Angular 18 migration target, write OnPush + signal-ready code and flip the zoneless switch later. Source: Angular: Zoneless guide.

DestroyRef and takeUntilDestroyed

DestroyRef is the modern cleanup primitive. It is injectable (so reusable cleanup logic is composable across functions) and pairs with takeUntilDestroyed() from @angular/core/rxjs-interop. Called from a constructor without an argument, takeUntilDestroyed() uses the component's own DestroyRef automatically.

import { Component, DestroyRef, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { interval } from 'rxjs';

@Component({ /* … */ })
export class TickerComponent {
  constructor() {
    interval(1000)
      .pipe(takeUntilDestroyed())
      .subscribe(value => console.log(value));
  }
}

Source: Angular: DestroyRef API.

toSignal and toObservable interop

@angular/core/rxjs-interop bridges the two reactive worlds. The "signal in, observable middle, signal out" pattern is now canonical for any reactive flow that needs RxJS operators (debounceTime, switchMap, combineLatest) but wants a signal at the consumer end.

import { toSignal, toObservable } from '@angular/core/rxjs-interop';
import { signal } from '@angular/core';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs';

const data = toSignal(this.http.get<User[]>('/users'), { initialValue: [] });

const query = signal('');
const results = toSignal(
  toObservable(query).pipe(
    debounceTime(300),
    distinctUntilChanged(),
    switchMap(q => this.search(q)),
  ),
  { initialValue: [] },
);

The subscription created by toSignal is automatically cleaned up when the owning injection context dies. Source: Angular: RxJS interop.

Signal-based viewChild and viewChildren

Decorator queries @ViewChild, @ViewChildren, @ContentChild, @ContentChildren still work but the signal-based equivalents eliminate the static: true vs static: false decision. The signal updates as the view changes.

import { Component, viewChild, viewChildren, computed } from '@angular/core';

@Component({ /* … */ })
export class TabsComponent {
  panel = viewChild.required<TabPanelComponent>('panel');
  buttons = viewChildren<HTMLButtonElement>('btn');
  buttonCount = computed(() => this.buttons().length);

  open() {
    this.panel().show();
  }
}

Source: Angular: Referencing component children with queries.

NgOptimizedImage with ngSrc

NgOptimizedImage replaces raw <img src="…"> with <img ngSrc="…"> plus required width and height and an optional priority flag. Non-priority images default to loading=lazy; priority images get fetchpriority=high, loading=eager, and an auto-generated <link rel="preload"> if SSR is in use. The directive emits actionable warnings if width / height are missing (CLS protection) or if the image is the LCP element and priority is not set.

<img ngSrc="/assets/hero.jpg" width="1200" height="800" priority alt="Hero" />
<img ngSrc="/assets/thumb.jpg" width="200" height="200" alt="Thumb" />

Source: Angular: Optimizing images with NgOptimizedImage.

Vitest as the default test runner

Karma was deprecated in Angular 16. The Angular CLI now uses Vitest as the default unit-test runner for new projects. Existing Karma + Jasmine projects continue to work; the migration is incremental. Jest had an experimental builder in v16; the team explicitly moved investment to Vitest because of its Vite alignment with the application builder.

import { TestBed } from '@angular/core/testing';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { UserListComponent } from './user-list.component';

describe('UserListComponent', () => {
  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [UserListComponent],
      providers: [provideHttpClientTesting()],
    });
  });

  it('renders', () => {
    const fixture = TestBed.createComponent(UserListComponent);
    fixture.detectChanges();
    expect(fixture.nativeElement.textContent).toContain('Users');
  });

  it('updates on input change', () => {
    const fixture = TestBed.createComponent(UserCardComponent);
    fixture.componentRef.setInput('user', { id: '1', name: 'Alice' });
    fixture.detectChanges();
    expect(fixture.componentInstance.user().name).toBe('Alice');
  });
});

fixture.componentRef.setInput() is the signal-aware way to drive an input()-defined input from a test. Source: Angular: Migrating from Karma to Vitest.

Application builder (Vite + esbuild) over browser builder

In Angular 18 the default builder for new projects is @angular-devkit/build-angular:application, which uses esbuild for production builds and Vite for the dev server. The legacy browser builder is supported but explicitly tagged as a migration target. Reported speed gains: more than 67% improvement in build time versus the old Webpack-based browser builder.

{
  "architect": {
    "build": {
      "builder": "@angular-devkit/build-angular:application",
      "options": { /* … */ }
    }
  }
}

Application builder advantages: same builder for client, server (SSR), and prerender; better tree-shaking; first-class support for @defer lazy chunking; HMR for component templates and styles. Migration is via ng update @angular/cli. Source: Angular: Migrating to new build system.

Canonical Angular 18 component (write everything like this)

import {
  Component, ChangeDetectionStrategy, inject, input, output, computed,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { UserService } from './user.service';

@Component({
  selector: 'app-user-list',
  standalone: true,
  imports: [],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <h2>{{ title() }}</h2>
    @if (loading()) {
      <p>Loading...</p>
    } @else if (users().length > 0) {
      <ul>
        @for (u of users(); track u.id) {
          <li>
            {{ u.name }}
            <button (click)="select.emit(u.id)">Open</button>
          </li>
        }
      </ul>
    } @else {
      <p>No users.</p>
    }
  `,
})
export class UserListComponent {
  private users$ = inject(UserService).list();

  filter = input<string>('');
  select = output<string>();

  private allUsers = toSignal(this.users$, { initialValue: [] });
  users = computed(() =>
    this.allUsers().filter(u => u.name.toLowerCase().includes(this.filter().toLowerCase())),
  );
  loading = computed(() => this.allUsers().length === 0);
  title = computed(() => `Users (${this.users().length})`);
}

Every primitive is Angular-18-canonical: standalone, OnPush, signal inputs, signal outputs, signals + computed for derived state, toSignal for RxJS interop, @if / @for / @empty control flow, inject() over constructor.

Migration paths

Three patterns. Each maps to a different organisational constraint.

Path A: Big-bang rewrite

The team freezes the AngularJS codebase, builds a fresh Angular 18 application against the same backend, and cuts traffic over on a release day.

When it wins. Small apps (under ~30K LOC by the XLTS formula), apps where the original requirements have drifted so far that a redesign is desired anyway, internal tools without external SLAs, apps so coupled that ngUpgrade boundaries are impractical. Reported timeline: 3 to 9 months for mid-sized applications.

When it fails. "Trying to do a big-bang rewrite is usually a recipe for disaster, especially with complex applications" is the consensus across migration consultancies. Feature freeze conflicts with business velocity; the new app accumulates parity bugs while the old app gets emergency patches that have to be re-implemented.

Tooling. Angular CLI fresh project (ng new), the standalone schematic (ng g @angular/core:standalone), control-flow schematic, inject() schematic.

Real-world cite. Grid Dynamics' fintech case study explicitly chose not to do big-bang for a multi-year financial-consultant app and went with ngUpgrade instead, naming parity risk as the deciding factor. Source: Grid Dynamics: AngularJS to Angular migration, Hashbyt 2026 roadmap.

Path B: ngUpgrade hybrid (recommended for most enterprise codebases)

The official Angular tool, @angular/upgrade/static, lets both frameworks run inside the same browser bundle. Bootstrap Angular first, then UpgradeModule.bootstrap brings AngularJS up against the same DOM root. From there, individual components and services migrate piecewise.

Primitives.

  • downgradeComponent({ component: NewAngularComponent }) returns an AngularJS directive factory. Drop the directive into an AngularJS template and you get an Angular 18 component rendering inside an AngularJS view.
  • upgradeComponent({ component: 'oldAngularJSDirective' }) wraps an AngularJS directive so it can be used inside Angular templates.
  • downgradeInjectable(NewAngularService) exposes an Angular service to the AngularJS DI container.

When it wins. Large apps with continuous-deployment requirements, regulated apps that cannot freeze, apps where parts of the surface have well-defined boundaries (settings page vs transaction grid).

When it loses. The hybrid bundle ships both frameworks; bundle size and startup cost go up before they come down. Routing is a chronic pain point: AngularJS router and Angular router fight for the URL, and lazy-loaded Angular bundles inside an AngularJS shell hit known UpgradeModule issues (angular/angular#17490).

Reported timeline: 6 to 18 months for a large enterprise app.

Real-world cite. Grid Dynamics fintech (AngularJS 1.6 + 1.9 to Angular 4 via UpgradeModule, bootstrapping Angular first then AngularJS), Codurance hybrid case study, Viacheslav Klavdiiev's 2025 retrospective on AngularJS to Angular 16 via UpgradeModule. Source: Angular v17 Upgrading from AngularJS guide, Codurance: Hybrid migration.

Path C: Strangler fig (build-on-the-side)

Stand up a new Angular 18 application as a separate deployable. A facade (reverse proxy, app shell, or container page) routes individual URLs to either the legacy AngularJS app or the new Angular app. Over time the share of URLs shifts toward Angular until the legacy app is retired.

Mechanisms.

  • Iframe shell. Each module renders inside an iframe; the host page coordinates auth and navigation. Manfred Steyer's microservice-angular-iframe repo is the canonical Angular reference.
  • Web components. Migrated Angular components are compiled to custom elements via @angular/elements and dropped into the AngularJS template as <my-component> tags.
  • Micro-frontends / single-spa. A meta-router orchestrates which framework runs which route. Small Improvements used a single-spa-like approach for their 100K-LOC AngularJS-to-React migration.

When it wins. Apps where the URL surface is naturally segmented (dashboard, settings, reports as independent areas), apps that want to ship the modernised UI under a new brand and keep the old UI accessible during transition, apps with multiple teams wanting independent release cadences.

When it loses. Cross-page state (auth tokens, feature flags, navigation breadcrumbs) requires explicit synchronisation. SEO and analytics frequently break. Iframe scrolling and focus-management UX issues are persistent.

Reported timeline: 6 to 24 months, highly variable. Source: Tenmile Square: Angular Migration and the Strangler Fig, Small Improvements: Migrating 100K LOC AngularJS to React.

Path summary

Path Tool Min app size where it wins Coexistence cost Typical timeline
A. Big-bang Angular CLI fresh project < ~30K LOC None (separate deployments) 3 - 9 months
B. ngUpgrade @angular/upgrade/static Any size; required for very large coupled apps Both frameworks in same bundle, hybrid routing complexity 6 - 18 months
C. Strangler fig iframes / @angular/elements / single-spa Mid-large with clean URL boundaries Cross-app state plumbing 6 - 24 months

Anti-patterns the assistant must refuse

The assistant SHALL NOT emit any of the patterns below. Each entry has a BAD code example (what LLMs trained pre-2025 produce by default), a CORRECT code example (the Angular 18 idiom), and a WHY one-liner with source.

Mode A: AngularJS 1.x emitted for "Angular" requests (15 patterns)

A-001: $scope.$apply and other $scope methods

// BAD - AngularJS 1.x
function MyController($scope) {
  setTimeout(function () {
    $scope.value = 42;
    $scope.$apply();
  }, 100);
}
// CORRECT - Angular 18
@Component({ /* … */ })
export class MyComponent {
  value = signal(0);
  constructor() {
    setTimeout(() => this.value.set(42), 100);
  }
}

WHY: $scope was removed in Angular 2.0 (September 2016) and does not exist in modern Angular. Signal writes mark consumers dirty automatically. Source: AngularJS Scopes guide.

A-002: $scope.$watch to observe a value

// BAD - AngularJS 1.x
$scope.$watch('user.name', function (newVal, oldVal) {
  console.log('changed', newVal);
});
// CORRECT - Angular 18
@Component({ /* … */ })
export class UserComponent {
  user = input.required<User>();
  constructor() {
    effect(() => console.log('changed', this.user().name));
  }
}

WHY: $watch runs on every digest cycle and is documented as an anti-pattern in community style guides; effect() replaces it for signals, RxJS subscriptions for streams. Source: AngularJS style-guide watch anti-pattern.

A-003: Controller registered against ng-controller

<!-- BAD - AngularJS 1.x -->
<div ng-controller="UserCtrl as vm">
  <h1>{{ vm.title }}</h1>
</div>
angular.module('app').controller('UserCtrl', function () {
  this.title = 'Users';
});
// CORRECT - Angular 18
@Component({
  selector: 'app-user',
  standalone: true,
  template: '<h1>{{ title }}</h1>',
})
export class UserComponent {
  title = 'Users';
}

WHY: Modern Angular has no Controller concept. Components are the unit of composition. Source: Angular: Standalone migration.

A-004: $compile to render dynamic HTML

// BAD - AngularJS 1.x
function MyDirective($compile) {
  return {
    link: function (scope, element) {
      var tpl = '<div>{{ user.name }}</div>';
      element.append($compile(tpl)(scope));
    },
  };
}
// CORRECT - Angular 18
@Component({
  standalone: true,
  imports: [NgComponentOutlet],
  template: '<ng-container *ngComponentOutlet="cmp(); inputs: inputs()" />',
})
export class HostComponent {
  cmp = signal(MyChildComponent);
  inputs = signal({ user: { name: 'Alice' } });
}

WHY: $compile does not exist in Angular. Runtime dynamic insertion uses ViewContainerRef.createComponent, NgComponentOutlet, or @defer. ngMigration Assistant flags $compile as a pre-migration refactor target. Source: ngMigration-Assistant README.

A-005: $http for HTTP

// BAD - AngularJS 1.x
$http.get('/api/users').then(function (response) {
  $scope.users = response.data;
});
// CORRECT - Angular 18
@Component({ /* … */ })
export class UsersComponent {
  private http = inject(HttpClient);
  users = toSignal(this.http.get<User[]>('/api/users'), { initialValue: [] });
}

WHY: $http returns a $q promise; Angular HttpClient returns an Observable, parses JSON automatically, supports cancellation and retry, and integrates with functional interceptors. Source: Angular: HttpClient API.

A-006: $q deferred promises

// BAD - AngularJS 1.x
function loadUser() {
  var deferred = $q.defer();
  $http.get('/api/user').then(function (r) { deferred.resolve(r.data); });
  return deferred.promise;
}
// CORRECT - Angular 18
loadUser(): Observable<User> {
  return this.http.get<User>('/api/user');
}

WHY: $q is AngularJS-only. Use native Promise for one-shot async, Observable for streams. The $q.defer() deferred antipattern was already discouraged in JS before AngularJS sunset. Source: AngularJS $q API.

A-007: ng-app directive on the root element

<!-- BAD - AngularJS 1.x -->
<html ng-app="myApp">
  <body>
    <div ng-view></div>
  </body>
</html>
<!-- CORRECT - Angular 18 index.html -->
<html>
  <body>
    <app-root></app-root>
  </body>
</html>
// main.ts
bootstrapApplication(AppComponent, { providers: [/* … */] });

WHY: ng-app is the AngularJS auto-bootstrap directive. Angular bootstraps explicitly through bootstrapApplication (standalone) or platformBrowser().bootstrapModule(AppModule) (legacy NgModule). Source: Angular: bootstrapApplication.

A-008: ng-bind-html with $sce.trustAsHtml

<!-- BAD - AngularJS 1.x -->
<div ng-bind-html="trustedHtml"></div>
$scope.trustedHtml = $sce.trustAsHtml(userInput);
// CORRECT - Angular 18
@Component({
  template: '<div [innerHTML]="safeHtml()"></div>',
})
export class CommentComponent {
  private sanitizer = inject(DomSanitizer);
  raw = input.required<string>();
  safeHtml = computed(() => this.sanitizer.bypassSecurityTrustHtml(this.raw()));
}

WHY: $sce.trustAsHtml is AngularJS's escape hatch and a notorious XSS vector; Angular's DomSanitizer is the equivalent and similarly dangerous. Sanitise server-side when possible. Source: Angular Security Guide.

A-009: Template event handler via ng-click

<!-- BAD - AngularJS 1.x -->
<button ng-click="vm.save()">Save</button>
<!-- CORRECT - Angular 18 -->
<button (click)="save()">Save</button>

WHY: Angular event bindings use (event) syntax. The ng-* attribute family belongs to AngularJS. Source: AngularJS migration guide.

A-010: AngularJS module system (angular.module('app', []))

// BAD - AngularJS 1.x
angular.module('app', ['ngRoute', 'ngResource'])
  .controller('MainCtrl', MainController)
  .service('UserService', UserService);
// CORRECT - Angular 18
@Component({
  standalone: true,
  imports: [RouterOutlet, UserCardComponent],
  template: '...',
})
export class AppComponent {
  private userService = inject(UserService);
}

WHY: Angular's standalone model replaces AngularJS's module system with ES module imports plus an imports array on each component. Source: Angular: Standalone migration.

A-011: Filter pipe with AngularJS-only grammar

<!-- BAD - AngularJS 1.x -->
{{ users | filter:search | orderBy:'name':true }}
<!-- CORRECT - Angular 18 -->
@for (u of filteredUsers(); track u.id) {
  <li>{{ u.name }}</li>
}
filteredUsers = computed(() =>
  this.users()
    .filter(u => u.name.includes(this.search()))
    .sort((a, b) => b.name.localeCompare(a.name)),
);

WHY: The | filter:expr and | orderBy:expr:reverse pipes from AngularJS do not exist in Angular. The framework explicitly omitted them for performance (they ran every digest cycle). Replace with computed signals. Source: Medium: Filters in AngularJS vs Pipes in Angular.

A-012: $resource for REST

// BAD - AngularJS 1.x
var User = $resource('/api/users/:id');
User.query(function (users) { $scope.users = users; });
User.get({ id: 1 }, function (u) { $scope.user = u; });
// CORRECT - Angular 18
@Injectable({ providedIn: 'root' })
export class UserService {
  private http = inject(HttpClient);
  list() { return this.http.get<User[]>('/api/users'); }
  get(id: string) { return this.http.get<User>(`/api/users/${id}`); }
}

WHY: $resource is an AngularJS-only abstraction over $http. Angular's HttpClient is itself the canonical REST client. Source: AngularJS $resource API.

A-013: $cookies service

// BAD - AngularJS 1.x
$cookies.put('token', 'abc');
var t = $cookies.get('token');
// CORRECT - Angular 18
@Injectable({ providedIn: 'root' })
export class CookieService {
  private doc = inject(DOCUMENT);
  set(name: string, value: string) {
    this.doc.cookie = `${name}=${encodeURIComponent(value)}; path=/`;
  }
}

WHY: ngCookies does not exist in Angular. Use the third-party ngx-cookie-service package or the DOCUMENT injection token to access document.cookie. Source: AngularJS $cookies provider.

A-014: $location for URL access

// BAD - AngularJS 1.x
$location.path('/users/' + id);
$location.search({ q: 'foo' });
// CORRECT - Angular 18
private router = inject(Router);
this.router.navigate(['/users', id], { queryParams: { q: 'foo' } });

WHY: Angular splits the AngularJS $location into Router (navigation) and Location (history). Source: Angular v17 upgrade guide: $location replacement.

A-015: IIFE wrap with 'use strict' per file

// BAD - AngularJS 1.x
(function () {
  'use strict';
  angular.module('app').service('Foo', Foo);
  function Foo() {}
})();
// CORRECT - Angular 18
// foo.service.ts
@Injectable({ providedIn: 'root' })
export class FooService {}

WHY: ES modules are strict by default; the IIFE wrap was an AngularJS-era convention to give each file its own scope for global-namespace angular.module('app') registration. Neither idiom is needed in TypeScript ES-module Angular. Source: Ultimate Courses: Minimal Angular module syntax using an IIFE.

Mode B: Angular 2-17 legacy emitted instead of Angular 18 (35 patterns)

The patterns below are valid Angular 2-17 code that still compiles in Angular 18 but is no longer the recommended target. LLMs trained on the v2-v12 era regress to these defaults unless explicitly steered.

B-001: *ngIf instead of @if

<!-- BAD - Angular 2-16 -->
<div *ngIf="user; else loading">{{ user.name }}</div>
<ng-template #loading><p>Loading...</p></ng-template>
<!-- CORRECT - Angular 18 -->
@if (user(); as u) {
  <div>{{ u.name }}</div>
} @else {
  <p>Loading...</p>
}

WHY: Control-flow @if stabilised in v18 and is the recommended pattern; *ngIf is on the long deprecation path. The migration schematic auto-converts. Source: HeroDevs: Control-flow migration schematic.

B-002: *ngFor without trackBy (and without @for's required track)

<!-- BAD - Angular 2-16 -->
<li *ngFor="let u of users">{{ u.name }}</li>
<!-- CORRECT - Angular 18 -->
@for (u of users(); track u.id) {
  <li>{{ u.name }}</li>
}

WHY: @for requires track. The compiler warns when missing because rebuilding the list on every change is the well-known *ngFor-without-trackBy performance bug. Source: Angular: Deferred loading and control flow.

B-003: *ngSwitch instead of @switch

<!-- BAD - Angular 2-16 -->
<div [ngSwitch]="status">
  <p *ngSwitchCase="'idle'">Idle</p>
  <p *ngSwitchDefault>Done</p>
</div>
<!-- CORRECT - Angular 18 -->
@switch (status()) {
  @case ('idle') { <p>Idle</p> }
  @default       { <p>Done</p> }
}

WHY: Same control-flow migration story as *ngIf / *ngFor. Source: HeroDevs: Control-flow migration schematic.

B-004: NgModule for a feature

// BAD - Angular 2-15
@NgModule({
  declarations: [UserListComponent, UserCardComponent],
  imports: [CommonModule, RouterModule.forChild(routes)],
  providers: [UserService],
  exports: [UserListComponent],
})
export class UserModule {}
// CORRECT - Angular 18
@Component({
  selector: 'app-user-list',
  standalone: true,
  imports: [UserCardComponent],
  template: '...',
})
export class UserListComponent {}

@Injectable({ providedIn: 'root' })
export class UserService {}

// routes.ts
export const userRoutes: Routes = [
  { path: '', loadComponent: () => import('./user-list.component').then(m => m.UserListComponent) },
];

WHY: Standalone is the recommended pattern in v18 and the default in v19. NgModule still works but is on the long deprecation runway. Source: The future is standalone.

B-005: @Input() decorator

// BAD - Angular 2-17
@Component({ /* … */ })
export class UserCard {
  @Input() user!: User;
  @Input() showAvatar = true;
}
// CORRECT - Angular 18
@Component({ /* … */ })
export class UserCardComponent {
  user = input.required<User>();
  showAvatar = input(true);
}

WHY: Signal-based input() and input.required() are recommended as of v17.1, give better types than the decorator, and auto-mark OnPush components dirty on change. Source: v18 docs: Inputs as signals.

B-006: @Output() decorator with EventEmitter

// BAD - Angular 2-17
@Component({ /* … */ })
export class UserCard {
  @Output() remove = new EventEmitter<string>();
}
// CORRECT - Angular 18
@Component({ /* … */ })
export class UserCardComponent {
  remove = output<string>();
}

WHY: output() is the signal-era equivalent: typed, lifecycle-managed, consistent with input(), no EventEmitter allocation. Source: Angular University: Signal Components.

B-007: Constructor injection everywhere

// BAD - Angular 2-15
@Component({ /* … */ })
export class UserListComponent {
  constructor(
    private http: HttpClient,
    private router: Router,
    private auth: AuthService,
  ) {}
}
// CORRECT - Angular 18
@Component({ /* … */ })
export class UserListComponent {
  private http = inject(HttpClient);
  private router = inject(Router);
  private auth = inject(AuthService);
}

WHY: inject() works in standalone functions (interceptors, guards, resolvers, factories) where constructors cannot, gives more accurate types, and is the framework's recommended default. Source: Angular: inject() function migration.

B-008: Class-based HttpInterceptor

// BAD - Angular 2-14
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
  constructor(private auth: AuthService) {}
  intercept(req: HttpRequest<any>, next: HttpHandler) {
    const token = this.auth.token;
    return next.handle(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }));
  }
}

@NgModule({
  providers: [{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }],
})
export class AppModule {}
// CORRECT - Angular 18
export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const token = inject(AuthService).token();
  return next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }));
};

bootstrapApplication(AppComponent, {
  providers: [provideHttpClient(withInterceptors([authInterceptor]))],
});

WHY: Functional interceptors have predictable ordering, no class-construction ambiguity. Source: Angular: Intercepting requests and responses.

B-009: Class-based route guard

// BAD - Angular 2-15.1
@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate {
  constructor(private auth: AuthService, private router: Router) {}
  canActivate(): boolean | UrlTree {
    return this.auth.isLoggedIn() ? true : this.router.parseUrl('/login');
  }
}
// CORRECT - Angular 18
export const authGuard: CanActivateFn = () => {
  const auth = inject(AuthService);
  const router = inject(Router);
  return auth.isLoggedIn() ? true : router.parseUrl('/login');
};

WHY: Class-based guards are deprecated as of v15.2. The function form is tree-shakable, composable, and recommended. Source: angular/angular#50234.

B-010: Class-based resolver

// BAD - Angular 2-15.1
@Injectable({ providedIn: 'root' })
export class UserResolver implements Resolve<User> {
  constructor(private svc: UserService) {}
  resolve(route: ActivatedRouteSnapshot) {
    return this.svc.get(route.params['id']);
  }
}
// CORRECT - Angular 18
export const userResolver: ResolveFn<User> = (route) =>
  inject(UserService).get(route.params['id']);

WHY: Same migration story as guards: function form is tree-shakable, composable, recommended. Source: angular/angular#50234.

B-011: HttpClientModule import

// BAD - Angular 2-17
@NgModule({
  imports: [HttpClientModule],
})
export class AppModule {}
// CORRECT - Angular 18
bootstrapApplication(AppComponent, {
  providers: [provideHttpClient()],
});

WHY: HttpClientModule is deprecated in v18 in favour of provideHttpClient(). Source: angular/angular#56964.

B-012: RouterModule.forRoot()

// BAD - Angular 2-14
@NgModule({
  imports: [RouterModule.forRoot(routes)],
})
export class AppModule {}
// CORRECT - Angular 18
bootstrapApplication(AppComponent, {
  providers: [provideRouter(routes, withComponentInputBinding())],
});

WHY: Standalone provider functions replace the module forms. withComponentInputBinding() enables route param flow into signal inputs. Source: Angular: withComponentInputBinding.

B-013: Promise.all for parallel HTTP

// BAD - older Angular
async load() {
  const [users, orders] = await Promise.all([
    firstValueFrom(this.http.get<User[]>('/users')),
    firstValueFrom(this.http.get<Order[]>('/orders')),
  ]);
}
// CORRECT - Angular 18
load() {
  return forkJoin({
    users: this.http.get<User[]>('/users'),
    orders: this.http.get<Order[]>('/orders'),
  });
}

WHY: forkJoin is the RxJS equivalent of Promise.all. Keeps the pipeline observable, allowing retry / cancellation / interceptor integration that Promises do not get. Source: Learn RxJS: forkJoin.

B-014: Subscribing in ngOnInit without unsubscription

// BAD - Angular 2-15
ngOnInit() {
  this.svc.stream().subscribe(value => this.value = value);
}
// CORRECT - Angular 18
// Option 1: takeUntilDestroyed
constructor() {
  this.svc.stream()
    .pipe(takeUntilDestroyed())
    .subscribe(value => this.value.set(value));
}

// Option 2: toSignal
value = toSignal(this.svc.stream(), { initialValue: null });

WHY: Bare .subscribe() without cleanup leaks the subscription when the component is destroyed. takeUntilDestroyed (v16+) uses DestroyRef to wire teardown automatically. Source: Angular: DestroyRef.

B-015: BehaviorSubject for component state

// BAD - Angular 2-15
private _user$ = new BehaviorSubject<User | null>(null);
user$ = this._user$.asObservable();
setUser(u: User) { this._user$.next(u); }
// CORRECT - Angular 18
user = signal<User | null>(null);
setUser(u: User) { this.user.set(u); }

WHY: Signals are the recommended primitive for local state; BehaviorSubject remains valid only when you need RxJS operators on the stream. Source: Modern Angular: Service with a Subject vs Service with a Signal.

B-016: ChangeDetectionStrategy.Default (implicit)

// BAD - implicit Default
@Component({
  selector: 'app-foo',
  template: '...',
})
export class FooComponent {}
// CORRECT - Angular 18
@Component({
  selector: 'app-foo',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: '...',
})
export class FooComponent {}

WHY: OnPush is the recommended default and the gateway to zoneless. RFC to make OnPush the default is open. Source: angular/angular#66779.

B-017: @ViewChild decorator with static: true | false

// BAD - Angular 2-16
@ViewChild('panel', { static: true }) panel!: ElementRef;
@ViewChild(ChildComponent, { static: false }) child!: ChildComponent;
// CORRECT - Angular 18
panel = viewChild.required<ElementRef>('panel');
child = viewChild(ChildComponent);

ngAfterViewInit() {
  this.panel().nativeElement.focus();
}

WHY: Signal-based viewChild() removes the static: true / static: false ergonomic problem. The signal updates as the view changes. Source: Angular: Referencing component children.

B-018: @ViewChildren / @ContentChildren

// BAD - Angular 2-16
@ViewChildren(ItemComponent) items!: QueryList<ItemComponent>;
ngAfterViewInit() {
  this.items.changes.subscribe(/* … */);
}
// CORRECT - Angular 18
items = viewChildren(ItemComponent);
itemCount = computed(() => this.items().length);

WHY: viewChildren() returns a signal whose value is the current list; combine with computed() for derived values without subscriptions. Source: Angular: Referencing component children.

B-019: Subscribing to ActivatedRoute.params

// BAD - Angular 2-17
constructor(private route: ActivatedRoute) {}
ngOnInit() {
  this.route.params.subscribe(p => this.id = p['id']);
}
// CORRECT - Angular 18
@Component({ /* … */ })
export class DetailComponent {
  id = input.required<string>(); // bound by withComponentInputBinding
}

WHY: With withComponentInputBinding() route params flow into component signal inputs automatically; no subscription, no ActivatedRoute injection. Source: DanyWalls: Simplify routing parameters.

B-020: Class-based TitleStrategy

// BAD - older Angular
@Injectable()
export class TemplatePageTitleStrategy extends TitleStrategy {
  override updateTitle(routerState: RouterStateSnapshot) {
    const title = this.buildTitle(routerState);
    if (title) document.title = `MyApp - ${title}`;
  }
}
// CORRECT - Angular 18
const routes: Routes = [
  { path: 'users', component: UserListComponent, title: 'Users - MyApp' },
];

// Or, for dynamic titles, a ResolveFn returning string:
const userTitle: ResolveFn<string> = (route) =>
  inject(UserService).get(route.params['id']).pipe(map(u => `User ${u.name}`));

WHY: Routes can specify static title or a ResolveFn. Class-based TitleStrategy overrides are a last resort, not the default. Source: Angular: withComponentInputBinding.

B-021: FormGroup without type interface (untyped reactive forms)

// BAD - pre-Angular-14
form = new FormGroup({
  email: new FormControl(''),
  password: new FormControl(''),
});
// form.value is { email: any; password: any }
// CORRECT - Angular 18
form = new FormGroup({
  email: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.email] }),
  password: new FormControl('', { nonNullable: true }),
});
// form.getRawValue() is { email: string; password: string }

WHY: Strictly typed reactive forms shipped in v14 and are the canonical pattern. Source: Angular: Strictly typed reactive forms.

B-022: Renderer2 for native DOM access

// BAD - older Angular
constructor(private el: ElementRef, private renderer: Renderer2) {}
focusInput() {
  this.renderer.selectRootElement(this.el.nativeElement.querySelector('input')).focus();
}
// CORRECT - Angular 18
input = viewChild.required<ElementRef<HTMLInputElement>>('input');
focusInput() { this.input().nativeElement.focus(); }

WHY: Renderer2 is still valid for server-rendered or web-worker contexts but for direct browser DOM access through a signal viewChild, the native element handle is type-safe and clearer. Source: Angular: Referencing component children.

B-023: HttpHeaders rebuilt via mutation pattern

// BAD
let headers = new HttpHeaders();
headers = headers.set('Authorization', `Bearer ${token}`);
headers = headers.set('X-Trace', traceId);
this.http.get('/api/users', { headers });
// CORRECT - Angular 18
this.http.get('/api/users', {
  headers: { Authorization: `Bearer ${token}`, 'X-Trace': traceId },
});

WHY: Modern HttpClient accepts a plain object for headers and params, removing the HttpHeaders re-assignment ceremony. Source: Angular: HttpClient API.

B-024: async/await inside a subscribe

// BAD
this.users$.subscribe(async users => {
  await this.cache.set('users', users);
  this.users = users;
});
// CORRECT - Angular 18
this.users$
  .pipe(
    switchMap(users => from(this.cache.set('users', users)).pipe(map(() => users))),
    takeUntilDestroyed(),
  )
  .subscribe(users => this.users.set(users));

WHY: Mixing async/await inside subscribe callbacks creates orphaned promises, breaks operator composition, and confuses error handling. Use switchMap / from to keep the pipeline observable. Source: Angular: RxJS interop.

B-025: import { map } from 'rxjs/operators'

// BAD - RxJS 6 era
import { map, filter, switchMap } from 'rxjs/operators';
// CORRECT - RxJS 7.4+ / Angular 18
import { map, filter, switchMap } from 'rxjs';

WHY: rxjs/operators is deprecated and slated for removal in a future major. Single-entry import from rxjs is the recommended pattern. Source: RxJS: Importing guide.

B-026: imports: [CommonModule] everywhere

// BAD - Angular 14-17
@Component({
  standalone: true,
  imports: [CommonModule],
  template: '<div *ngIf="x">{{ x }}</div>',
})
// CORRECT - Angular 18
@Component({
  standalone: true,
  imports: [],
  template: '@if (x()) { <div>{{ x() }}</div> }',
})

WHY: Built-in control flow does not require CommonModule. Importing it pulls in unused directives. Source: HeroDevs: Control-flow migration schematic.

B-027: <img src="…"> without NgOptimizedImage

<!-- BAD -->
<img src="/assets/hero.jpg" />
<!-- CORRECT - Angular 18 -->
<img ngSrc="/assets/hero.jpg" width="1200" height="800" priority alt="Hero" />

WHY: NgOptimizedImage enforces width/height (CLS protection), defaults to lazy loading, and supports priority for LCP elements. Source: Angular: Optimizing images with NgOptimizedImage.

B-028: (ngModelChange) for two-way binding instead of model()

<!-- BAD - older Angular -->
<input [ngModel]="name" (ngModelChange)="name = $event" />
<!-- CORRECT - Angular 18 (template-driven) -->
<input [(ngModel)]="name" />

<!-- CORRECT - Angular 18 (signal model() in child component) -->
<my-input [(value)]="name" />
// In the child component
value = model('');

WHY: For component-to-component two-way binding, model() provides a signal-based two-way contract. Source: v18 docs: Inputs as signals.

B-029: [innerHTML] without explicit sanitisation context

// BAD
@Component({
  template: '<div [innerHTML]="raw"></div>',
})
export class CommentComponent {
  raw = '<script>alert(1)</script><b>hi</b>';
}
// CORRECT - Angular 18
@Component({
  template: '<div [innerHTML]="safe()"></div>',
})
export class CommentComponent {
  raw = input.required<string>();
  private sanitizer = inject(DomSanitizer);
  safe = computed(() => this.sanitizer.sanitize(SecurityContext.HTML, this.raw()));
}

WHY: [innerHTML] runs the sanitiser by default, but explicit sanitisation makes the policy auditable. Source: Angular Security Guide.

B-030: Lazy-loading via loadChildren string syntax

// BAD - Angular 8-
{ path: 'users', loadChildren: './users/users.module#UsersModule' }
// CORRECT - Angular 18
{ path: 'users', loadChildren: () => import('./users/users.routes').then(m => m.USER_ROUTES) }
// or:
{ path: 'users', loadComponent: () => import('./users/users.component').then(m => m.UsersComponent) }

WHY: String-based loadChildren was deprecated in Angular 9; the dynamic-import form is the only supported syntax. Standalone components also support loadComponent. Source: Angular: Standalone migration.

B-031: ngOnInit for one-time derived computation

// BAD
ngOnInit() {
  this.total = this.items.reduce((s, i) => s + i.price, 0);
}
// CORRECT - Angular 18
items = input.required<Item[]>();
total = computed(() => this.items().reduce((s, i) => s + i.price, 0));

WHY: computed() is reactive; when items changes, total recomputes. The ngOnInit form runs once and goes stale if inputs change. Source: Angular: Signals.

B-032: Tap-then-subscribe instead of async pipe / toSignal

// BAD
@Component({ template: '<div>{{ count }}</div>' })
export class CounterComponent {
  count = 0;
  constructor() {
    this.svc.count$.pipe(tap(c => this.count = c)).subscribe();
  }
}
// CORRECT - Angular 18
@Component({ template: '<div>{{ count() }}</div>' })
export class CounterComponent {
  count = toSignal(inject(MyService).count$, { initialValue: 0 });
}

WHY: toSignal (and async pipe) handle subscription and cleanup; the imperative pattern leaks if .subscribe() is not torn down. Source: Angular: toSignal API.

B-033: HostBinding and HostListener decorators

// BAD - older Angular
@Directive({ selector: '[appHover]' })
export class HoverDirective {
  @HostBinding('class.active') active = false;
  @HostListener('mouseenter') onEnter() { this.active = true; }
  @HostListener('mouseleave') onLeave() { this.active = false; }
}
// CORRECT - Angular 18
@Directive({
  selector: '[appHover]',
  standalone: true,
  host: {
    '[class.active]': 'active()',
    '(mouseenter)': 'active.set(true)',
    '(mouseleave)': 'active.set(false)',
  },
})
export class HoverDirective {
  active = signal(false);
}

WHY: The host: {} literal in decorator metadata is the recommended replacement; it keeps all host bindings in one place and is friendlier to template tooling. Source: Angular: Standalone migration.

B-034: enableProdMode() in main.ts

// BAD - Angular 2-15
if (environment.production) {
  enableProdMode();
}
platformBrowserDynamic().bootstrapModule(AppModule);
// CORRECT - Angular 18
bootstrapApplication(AppComponent, appConfig);
// enableProdMode is set automatically by the application builder

WHY: The application builder auto-sets prod mode based on the build configuration. Manual enableProdMode() calls are legacy. Source: Angular: Migrating to new build system.

B-035: Browser builder in angular.json

// BAD - Angular 2-15
{
  "architect": {
    "build": {
      "builder": "@angular-devkit/build-angular:browser",
      "options": { /* … */ }
    }
  }
}
// CORRECT - Angular 18
{
  "architect": {
    "build": {
      "builder": "@angular-devkit/build-angular:application",
      "options": { /* … */ }
    }
  }
}

WHY: The application builder is the v18 default and the only one that supports @defer chunking, SSR, prerender, and HMR in one chain. ng update migrates this. Source: Angular: Migrating to new build system.

Mode C: Migration-specific mistakes (12 patterns)

These appear specifically during AngularJS to Angular 18 work, where the LLM is making decisions about migration strategy, tooling, and bridging code.

C-001: Recommending big-bang rewrite without considering ngUpgrade

BAD advice:

"Rewrite your AngularJS application from scratch in Angular 18. Plan a 4-week feature freeze."

CORRECT advice:

"For apps over ~30K LOC, use the @angular/upgrade/static hybrid path: bootstrap Angular first, then UpgradeModule.bootstrap AngularJS against the same root. Migrate component-by-component using downgradeComponent / upgradeComponent. For apps with cleanly partitioned URL surface, consider the strangler-fig pattern with @angular/elements. Big-bang rewrites of large enterprise apps are widely reported as the riskiest path."

WHY: Multiple migration consultancies converge on "big bang fails for large complex apps." Grid Dynamics, Codurance, and Hashbyt all document this explicitly. Source: Hashbyt 2026 migration roadmap.

C-002: Recommending unmaintained AngularJS community packages

BAD advice:

"Use the angular-ui-router package for routing in your AngularJS app."

CORRECT advice:

"AngularJS itself reached EOL in January 2022; only the HeroDevs NES fork ships post-EOL security patches. For any new code, use Angular's first-party provideRouter with standalone Routes. If you must continue developing in AngularJS, evaluate HeroDevs NES rather than relying on community packages that stopped receiving updates years ago."

WHY: Community AngularJS packages (angular-ui-router, restangular, angular-translate) are mostly unmaintained. Recommending them in 2026 ships known unpatched CVE risk. Source: HeroDevs AngularJS NES.

C-003: Mixing AngularJS and Angular imports in one file

// BAD
import { Component } from '@angular/core';
import * as angular from 'angular'; // AngularJS in an Angular file

@Component({ /* … */ })
export class MyComponent {
  constructor() {
    angular.module('legacyApp').service(/* … */); // smells like trouble
  }
}
// CORRECT - dedicated bridge file
// bridges/my-angular.bridge.ts
import * as angular from 'angular';
import { downgradeComponent } from '@angular/upgrade/static';
import { MyAngularComponent } from '../my-angular.component';

angular.module('legacyApp')
  .directive('myAngularComponent',
    downgradeComponent({ component: MyAngularComponent }) as angular.IDirectiveFactory);

WHY: Hybrid wiring belongs in dedicated bridge files, not interleaved with component logic. Mixing causes circular dependencies and breaks tree-shaking. Source: Angular v17 upgrade guide.

C-004: Forgetting downgradeInjectable for a shared service

// BAD
@Injectable({ providedIn: 'root' })
export class AuthService { /* … */ }

// Legacy AngularJS code calls Auth.token() and gets undefined,
// because the Angular service is not registered with the AngularJS injector.
// CORRECT - bridge file
import { downgradeInjectable } from '@angular/upgrade/static';
import { AuthService } from './auth.service';

angular.module('legacyApp')
  .factory('authService', downgradeInjectable(AuthService) as any);

WHY: Without downgradeInjectable the AngularJS DI container cannot resolve the Angular service. Source: DigitalOcean: Migrate services with ngUpgrade.

C-005: Forgetting downgradeComponent when an Angular component is used in an AngularJS template

<!-- BAD - AngularJS template, renders nothing -->
<user-card user="vm.user"></user-card>
// CORRECT - bridge file
angular.module('legacyApp')
  .directive('userCard',
    downgradeComponent({ component: UserCardComponent }) as angular.IDirectiveFactory);

WHY: AngularJS only knows directives registered through angular.module(…).directive(…). downgradeComponent wraps an Angular component as an AngularJS directive. Source: Angular v17 upgrade guide.

C-006: Wrong bootstrap order in hybrid app

// BAD
angular.bootstrap(document.body, ['legacyApp']);
platformBrowserDynamic().bootstrapModule(AppModule);
// CORRECT - Angular 18 hybrid
@NgModule({
  imports: [BrowserModule, UpgradeModule],
})
export class AppModule {
  constructor(private upgrade: UpgradeModule) {}
  ngDoBootstrap() {
    this.upgrade.bootstrap(document.body, ['legacyApp']);
  }
}

platformBrowserDynamic().bootstrapModule(AppModule);

WHY: Angular must bootstrap first; the UpgradeModule.bootstrap call then brings AngularJS up against the same root element. The reverse order leaves both frameworks fighting for the same DOM. Source: Angular v17 upgrade guide.

C-007: Assuming ng update covers AngularJS to Angular

BAD advice:

"Run ng update @angular/core from your AngularJS app to upgrade to Angular 18."

CORRECT advice:

"ng update upgrades between Angular versions (v17 to v18, v18 to v19). It does not migrate AngularJS to Angular. For that, use ngUpgrade hybrid (manual integration via @angular/upgrade/static), the strangler-fig pattern, or a full rewrite. Run ngMigration Assistant (npm i -g ngma) for an effort estimate on the AngularJS codebase first."

WHY: ng update is for the Angular 2+ line. AngularJS to Angular is a framework-replacement migration, not a version upgrade. Source: ngMigration-Assistant.

C-008: Wrong RxJS version assumption

// BAD
"dependencies": { "rxjs": "6.6.0" }
// CORRECT - Angular 18
"dependencies": { "rxjs": "^7.4.0" }

WHY: Angular 18 requires RxJS 7.4 or higher. RxJS 6 is end-of-life and incompatible with the Angular 18 type definitions. Source: Angular: Version compatibility.

C-009: Translating $rootScope.$broadcast / $emit / $on literally

BAD prompt:

"Find an equivalent to $rootScope.$broadcast('user-changed', user) in Angular."

CORRECT response:

"Don't translate the event-bus pattern. Use a dedicated service (a singleton injected into both sender and receiver) that exposes a signal or a Subject. The AngularJS $rootScope event bus was already considered an anti-pattern in 2015-era style guides; ngMigration Assistant explicitly flags $rootScope for refactoring before migration."

// Replacement pattern
@Injectable({ providedIn: 'root' })
export class UserEventService {
  readonly userChanged = signal<User | null>(null);
}

// Sender
inject(UserEventService).userChanged.set(updatedUser);

// Receiver
private events = inject(UserEventService);
constructor() {
  effect(() => {
    const u = this.events.userChanged();
    if (u) this.onUserChanged(u);
  });
}

WHY: $rootScope proliferation is one of the patterns the ngMigration Assistant calls out: "App contains $rootScope, please refactor rootScope into services." Source: ngMigration-Assistant README.

C-010: Translating ng-class literally to [ngClass] and stopping there

<!-- BAD intermediate result -->
<div [ngClass]="{ 'active': isActive, 'disabled': isDisabled }">...</div>
<!-- CORRECT - Angular 18 target -->
<div [class.active]="isActive()" [class.disabled]="isDisabled()">...</div>

WHY: [ngClass] works but the binding-shorthand [class.foo] is preferred for static class names; [ngClass] is reserved for dynamic key maps. The shorthand also reduces template change-detection cost. Source: Angular: Standalone migration.

C-011: Importing BrowserAnimationsModule in a standalone app

// BAD - Angular 14-17 hybrid output
@NgModule({
  imports: [BrowserAnimationsModule],
})
export class AppModule {}
// CORRECT - Angular 18 standalone
bootstrapApplication(AppComponent, {
  providers: [provideAnimations()], // or provideAnimationsAsync() for lazy
});

WHY: Standalone apps use provider functions, not module imports. Source: Angular: Standalone migration.

C-012: Leaving zone.js polyfill when going zoneless

// BAD - mixed setup
// polyfills.ts
import 'zone.js';

// main.ts
bootstrapApplication(AppComponent, {
  providers: [provideZonelessChangeDetection()],
});
// CORRECT - Angular 18 zoneless dev preview
// polyfills.ts - remove the zone.js import

// main.ts
bootstrapApplication(AppComponent, {
  providers: [provideZonelessChangeDetection()],
});
// angular.json - remove zone.js from polyfills array
{
  "architect": {
    "build": {
      "options": {
        "polyfills": []
      }
    }
  }
}

WHY: Both shipping Zone.js and enabling zoneless change detection defeats the bundle-size and startup-cost goal of going zoneless. Source: Angular: Zoneless guide.

Decision tree: when to recommend which migration path

Condition Recommended path
App is under ~30K LOC, no continuous-deployment requirement, team can freeze for 3-9 months Path A (big-bang)
App is 30-300K LOC, must keep shipping, has coupled component boundaries Path B (ngUpgrade hybrid)
App is large with cleanly partitioned URL surface (dashboard / settings / reports), multiple teams want independent cadence Path C (strangler fig)
App is over 300K LOC Path C with multi-team plan, 18-36+ months
Auditor or compliance freeze prevents any migration Stay on AngularJS under HeroDevs NES or OpenLogic by Perforce until the freeze lifts
Team is debating React / Vue / Svelte as the target Angular 18 is the only target with first-party hybrid coexistence (@angular/upgrade/static); pick another target only if there is a non-technical reason

Sizing heuristic from XLTS.dev: weeks = (LOC / weekly_team_output_LOC) * 1.4. Example: 100K LOC at 1K LOC / week per team is ~140 person-weeks, roughly 2.7 calendar years at steady state. Source: XLTS.dev: The Math of Migrating from AngularJS.

App size LOC range Typical path Realistic calendar (team of 4-8)
Small < 20K Big-bang 2-4 months
Medium 20-80K Big-bang or ngUpgrade 4-9 months
Large 80-300K ngUpgrade or strangler-fig 9-18 months
Very large > 300K Strangler-fig with multi-team plan 18-36+ months

Common ngUpgrade gotchas

  1. Bootstrap order is fixed. Angular bootstraps first via platformBrowserDynamic().bootstrapModule(AppModule); UpgradeModule.bootstrap(document.body, ['legacyApp']) brings AngularJS up against the same DOM root from inside ngDoBootstrap. Reverse order leaves both frameworks fighting for the DOM (see C-006).
  2. Both frameworks ship in the bundle. Hybrid bundle size and startup cost go up before they come down. Plan to measure and budget for the regression during the migration window.
  3. Routing is the chronic pain point. The AngularJS router and the Angular router fight for the URL. Lazy-loaded Angular bundles inside an AngularJS shell hit angular/angular#17490. Pick one router as the master and downgrade / upgrade individual routes off it.
  4. Every shared boundary needs explicit wiring. Angular component used in AngularJS template requires downgradeComponent (C-005). Angular service called from AngularJS code requires downgradeInjectable (C-004). AngularJS directive used in Angular template requires upgradeComponent. Missing any of these silently fails (the directive renders nothing, the service returns undefined).
  5. Bridge files belong in their own directory. Keep all downgradeComponent / downgradeInjectable / upgradeComponent calls in bridges/ (or similar). Interleaving them with component logic causes circular imports and breaks tree-shaking (C-003).
  6. $rootScope.$broadcast does not translate. Refactor event-bus usage into a shared service exposing a signal or Subject before the migration starts, not during (C-009).
  7. $compile-using directives are a special case. They need an architectural rethink (dynamic component creation via ViewContainerRef.createComponent, NgComponentOutlet, or @defer), not a mechanical port (A-004).
  8. Karma + Jasmine + ngMock tests do not port. Plan to rewrite the test suite in Vitest + Angular TestBed, not migrate the AngularJS specs. Protractor was deprecated in 2022 alongside AngularJS; replace e2e with Cypress or Playwright.

References

Angular 18 official documentation

AngularJS EOL and CVE

Commercial extended support

Migration case studies and tooling

Deprecations and feature RFCs

LLM / Copilot context