DEV Community

Olivia Craft
Olivia Craft

Posted on

Cursor Rules for Angular: The Complete Guide to AI-Assisted Angular Development

Cursor Rules for Angular: The Complete Guide to AI-Assisted Angular Development

Angular is the framework where "it compiles" hides the longest lie. The page renders, the router navigates, and nothing in ng build tells you that a component under a default-change-detection parent re-runs twenty bindings every time the mouse moves, that a Subscription inside ngOnInit is still emitting three pages later because nobody wired takeUntilDestroyed, or that the "reactive" form typed as FormGroup is a dynamic bag of AbstractControl | nulls the template happily calls .value.email on and gets undefined in prod. The app ships. A flame graph six months later shows the same pipe recomputing a currency format fourteen times per tick.

Then you add an AI assistant.

Cursor and Claude Code were trained on a planet's worth of Angular. Most of it is Angular 2 through 15 — NgModules, constructor DI, untyped FormGroup, subscribe everywhere, CommonModule imported in every standalone component that only uses one directive. Ask for "a dashboard that loads a user and lets you edit them," and you get a component with a dozen @Input()s, a manually-managed Subscription[], a template calling user?.address?.city?.toUpperCase() with no type safety, and a NgModule that exists only to declare it. It compiles. It's not the Angular you would ship in 2026.

The fix is .cursorrules — one file in the repo that tells the AI what idiomatic modern Angular looks like. Eight rules below, each with the failure mode, the rule, and a before/after. Copy-paste .cursorrules at the end.

How Cursor Rules Work for Angular Projects

Cursor reads project rules from two locations: .cursorrules (a single file at the repo root, still supported) and .cursor/rules/*.mdc (modular files with frontmatter, recommended for anything bigger than a single app). For Angular I recommend modular rules so an Nx monorepo's server-rendering conventions don't bleed into a library project's public-API constraints:

.cursor/rules/
  angular-core.mdc       # signals, standalone, change detection
  angular-forms.mdc      # typed reactive forms, validators
  angular-rxjs.mdc       # pipe operators, destroy discipline
  angular-routing.mdc    # lazy loading, guards, resolvers
  angular-testing.mdc    # TestBed, harnesses, component tests
Enter fullscreen mode Exit fullscreen mode

Frontmatter controls activation: globs: ["**/*.ts", "**/*.html"] with alwaysApply: false. Now the rules.

Rule 1: Strict Typing — Signals for State, Typed Reactive Forms Always

The most common AI failure in Angular is the any-shaped form. Cursor generates new FormGroup({ email: new FormControl(''), age: new FormControl(0) }) with no generic, so form.value.email is any and form.get('age') returns AbstractControl | null. Two refactors later the template binds form.value.emial with a typo and nothing complains. Same story with state: Cursor reaches for BehaviorSubject<any> when a signal<User | null>(null) with full inference would be tighter, faster, and render-aware.

The rule:

tsconfig strict: true + strictTemplates: true + strictInputAccessModifiers.
No `any`. Unknown external data is typed `unknown` and narrowed.

Component state uses `signal<T>()`, `computed()`, and `input()` /
`model()` — never raw class fields mutated from handlers, never
BehaviorSubject as a local store.

Reactive forms use FormBuilder.nonNullable with typed FormGroup /
FormControl<T>. Every control's value type is visible to the template.
No untyped FormGroup, no `as FormGroup` casts, no `form.get('x').value`
without a generic.

Validators are typed: ValidatorFn for sync, AsyncValidatorFn for async.
Custom validators return ValidationErrors | null, not plain objects.
Enter fullscreen mode Exit fullscreen mode

Before — untyped form, imperative state, any through the template:

@Component({ ... })
export class ProfileComponent {
  user: any = null;
  form = new FormGroup({
    email: new FormControl(''),
    age: new FormControl(0),
    prefs: new FormGroup({ newsletter: new FormControl(false) }),
  });

  ngOnInit() {
    this.api.getUser().subscribe(u => {
      this.user = u;
      this.form.patchValue(u);
    });
  }

  save() {
    // form.value.email is `any` — typo here will silently ship
    this.api.save(this.form.value.emial!);
  }
}
Enter fullscreen mode Exit fullscreen mode

After — signals for state, typed non-nullable form, inferred everywhere:

type Prefs = { newsletter: boolean };
type User = { email: string; age: number; prefs: Prefs };

@Component({ ... })
export class ProfileComponent {
  private fb = inject(NonNullableFormBuilder);
  private api = inject(UserApi);

  user = signal<User | null>(null);
  saving = signal(false);
  canSubmit = computed(() => this.form.valid && !this.saving());

  form = this.fb.group({
    email: this.fb.control('', [Validators.required, Validators.email]),
    age: this.fb.control(0, [Validators.min(0)]),
    prefs: this.fb.group({ newsletter: this.fb.control(false) }),
  });

  constructor() {
    this.api.getUser().pipe(takeUntilDestroyed()).subscribe(u => {
      this.user.set(u);
      this.form.patchValue(u);
    });
  }

  save() {
    if (!this.form.valid) return;
    // form.getRawValue() is fully typed { email: string; age: number; prefs: { newsletter: boolean } }
    this.api.save(this.form.getRawValue()).subscribe();
  }
}
Enter fullscreen mode Exit fullscreen mode

form.value.emial no longer compiles. this.user() is User | null, narrowable with a guard. Change detection knows when a signal is read.

Rule 2: Standalone Components Over NgModules — Stop Declaring the World

Every Angular app built before v16 shipped an AppModule that imported BrowserModule, CommonModule, FormsModule, and twelve feature modules, each of which re-exported CommonModule because the author didn't trust tree-shaking. Cursor still generates this by default. Standalone components — default since v17, the only supported path for new code — eliminate module boilerplate and make dependencies explicit per component.

The rule:

All new components, directives, and pipes are `standalone: true`. No
new NgModules.

`imports: []` lists only what the template actually uses. No
CommonModule-by-reflex; NgIf, NgFor, and pipes are imported
individually or use the built-in control flow (@if, @for, @switch).

Bootstrapping uses bootstrapApplication() with provideRouter(),
provideHttpClient(withFetch()), provideAnimationsAsync(), and
feature-specific providers — no AppModule.

Legacy NgModules only when integrating with an older library that
requires them. Document the reason in the `imports` entry.
Enter fullscreen mode Exit fullscreen mode

Before — NgModule for a single component, CommonModule blanket import:

@NgModule({
  declarations: [UserCardComponent],
  imports: [CommonModule, FormsModule, ReactiveFormsModule, RouterModule],
  exports: [UserCardComponent],
})
export class UserCardModule {}

@Component({
  selector: 'app-user-card',
  templateUrl: './user-card.component.html',
})
export class UserCardComponent {
  @Input() user!: User;
}
Enter fullscreen mode Exit fullscreen mode

Template uses *ngIf, which pulls CommonModule for one directive.

After — standalone component, built-in control flow, no module:

@Component({
  selector: 'app-user-card',
  standalone: true,
  imports: [RouterLink, DatePipe],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @if (user(); as u) {
      <a [routerLink]="['/users', u.id]">{{ u.name }}</a>
      <time>{{ u.joinedAt | date: 'mediumDate' }}</time>
    } @else {
      <span>No user</span>
    }
  `,
})
export class UserCardComponent {
  user = input.required<User>();
}
Enter fullscreen mode Exit fullscreen mode

No module. @if replaces *ngIf. Bundle is ~12KB smaller per route.

Rule 3: OnPush Change Detection — The Default That Isn't the Default

Angular's default change-detection strategy re-runs every binding in every component on every macrotask. OnPush flips the model: a component re-runs bindings only when an @Input() reference changes, an event fires inside it, an observable bound with async emits, or a signal it reads changes. Most Cursor-generated components omit the strategy and eat the performance cost silently.

The rule:

Every component uses changeDetection: ChangeDetectionStrategy.OnPush.
No exceptions for "it's a small component."

Inputs passed to OnPush children must be immutable references — new
object/array on change, not in-place mutation. Prefer signal inputs
(input(), input.required()) which integrate with OnPush automatically.

Observables rendered in the template go through the `async` pipe or
a signal created with toSignal(). Never subscribe in the component
and assign to a field for the template to read.

For manual triggers (third-party events, requestAnimationFrame
callbacks), use ChangeDetectorRef.markForCheck(), never detectChanges().
Enter fullscreen mode Exit fullscreen mode

Before — default CD, manual subscribe, mutation in place:

@Component({
  selector: 'app-order-list',
  template: `
    <div *ngFor="let o of orders">{{ o.total | currency }}</div>
    <button (click)="addFee()">Add fee</button>
  `,
})
export class OrderListComponent implements OnInit {
  orders: Order[] = [];

  constructor(private api: OrderApi) {}

  ngOnInit() {
    this.api.list().subscribe(o => (this.orders = o));
  }

  addFee() {
    this.orders.forEach(o => (o.total += 5)); // mutates in place
  }
}
Enter fullscreen mode Exit fullscreen mode

Runs every pipe on every mousemove. addFee mutates array items — under OnPush the view wouldn't update.

After — OnPush, signals, immutable updates:

@Component({
  selector: 'app-order-list',
  standalone: true,
  imports: [CurrencyPipe],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @for (o of orders(); track o.id) {
      <div>{{ o.total | currency }}</div>
    }
    <button (click)="addFee()">Add fee</button>
  `,
})
export class OrderListComponent {
  private api = inject(OrderApi);
  orders = toSignal(this.api.list(), { initialValue: [] as Order[] });

  private feeSignal = signal(0);
  // Derived: original list + any accumulated fee
  private _view = computed(() =>
    this.orders().map(o => ({ ...o, total: o.total + this.feeSignal() })));

  addFee() {
    this.feeSignal.update(f => f + 5);
  }
}
Enter fullscreen mode Exit fullscreen mode

Pipes only re-evaluate when orders or feeSignal change. No mutation. No manual subscription.

Rule 4: Dependency Injection With inject() — Stop Writing Constructors

Constructor injection was Angular's one true way for eight years. It is now the legacy style. inject() (available in component, directive, pipe, service, guard, and resolver contexts) gives you typed, tree-shakable DI without the ceremony, works in field initializers (so you can use DI to build signal / computed at class-field level), and composes into reusable functions for cancellation, destruction, and router integration.

The rule:

New classes use `inject(Token)` at field-declaration time. No
constructor parameters for DI.

Public fields stay public only if the template needs them. Everything
else is private or readonly.

Extract DI combos into helper functions: `function useUserApi()` that
calls `inject()` internally. Call from within an injection context.

`inject(DestroyRef).onDestroy(...)` for teardown. `takeUntilDestroyed()`
for RxJS streams. Never hand-rolled OnDestroy + Subscription arrays.

Provide services with `providedIn: 'root'` unless scoped; inject
feature services via `provideX()` helpers, not providers arrays on
components.
Enter fullscreen mode Exit fullscreen mode

Before — constructor DI, manual subscription bookkeeping:

@Component({ ... })
export class UserPageComponent implements OnInit, OnDestroy {
  private subs = new Subscription();
  user: User | null = null;

  constructor(
    private route: ActivatedRoute,
    private api: UserApi,
    private cdr: ChangeDetectorRef,
  ) {}

  ngOnInit() {
    this.subs.add(
      this.route.paramMap.subscribe(p => {
        this.subs.add(
          this.api.getUser(p.get('id')!).subscribe(u => {
            this.user = u;
            this.cdr.markForCheck();
          }),
        );
      }),
    );
  }

  ngOnDestroy() {
    this.subs.unsubscribe();
  }
}
Enter fullscreen mode Exit fullscreen mode

After — inject(), signals from observables, auto-destroy:

@Component({
  selector: 'app-user-page',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @if (user(); as u) {
      <h1>{{ u.name }}</h1>
    }
  `,
})
export class UserPageComponent {
  private route = inject(ActivatedRoute);
  private api = inject(UserApi);

  private userId = toSignal(
    this.route.paramMap.pipe(map(p => p.get('id')!)),
    { requireSync: true },
  );

  user = toSignal(
    toObservable(this.userId).pipe(switchMap(id => this.api.getUser(id))),
    { initialValue: null },
  );
}
Enter fullscreen mode Exit fullscreen mode

No Subscription, no ngOnDestroy, no markForCheck. The template reads a signal. Teardown is handled by toSignal's injection context.

Rule 5: RxJS Pipe Discipline — No Nested Subscribes, No Manual Teardown

Nested subscribe inside subscribe is the bug that keeps writing itself. Cursor generates it because the most naive translation of "when A happens, fetch B" is to subscribe to A and subscribe to B inside. The result is an uncancellable chain, race conditions on every navigation, and a memory leak every time the outer observable re-emits. Every nested subscribe should be a switchMap, mergeMap, concatMap, or exhaustMap.

The rule:

One subscribe per chain, at the edge (template via `async` pipe / toSignal,
or an effect). Never subscribe-inside-subscribe.

Picking the higher-order operator:
  switchMap  — latest wins (search, route params, autocomplete)
  mergeMap   — all in parallel, order doesn't matter (logging)
  concatMap  — one-at-a-time, order matters (sequential saves)
  exhaustMap — ignore new while one runs (form submit, login)

takeUntilDestroyed() at the end of every component/service pipe that
does subscribe manually. No per-subscription cleanup.

Side effects go in `tap()`. Never in `map()`. Never mutate the value
passing through the stream.

Share long-lived hot streams with shareReplay({ bufferSize: 1,
refCount: true }) — never plain shareReplay() (leaks subscriptions).
Enter fullscreen mode Exit fullscreen mode

Before — nested subscribes, race on every param change, leak on destroy:

ngOnInit() {
  this.route.paramMap.subscribe(params => {
    const id = params.get('id')!;
    this.api.getUser(id).subscribe(user => {
      this.user = user;
      this.api.getOrders(user.id).subscribe(orders => {
        this.orders = orders;
      });
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

Navigate between three users quickly — all three fetches race, and whichever returns last wins regardless of current URL.

After — flat pipe, switchMap for the latest-wins semantics, single subscribe:

data = toSignal(
  this.route.paramMap.pipe(
    map(p => p.get('id')!),
    distinctUntilChanged(),
    switchMap(id =>
      this.api.getUser(id).pipe(
        switchMap(user =>
          this.api.getOrders(user.id).pipe(map(orders => ({ user, orders }))),
        ),
      ),
    ),
    takeUntilDestroyed(),
  ),
  { initialValue: null },
);
Enter fullscreen mode Exit fullscreen mode

No leak. No race. Navigating away cancels the in-flight fetch. Template reads data()?.user and data()?.orders.

Rule 6: Template Type Checking — strictTemplates Is Not Optional

strictTemplates: true in tsconfig.json makes Angular's compiler check template bindings against the component's typed interface. Without it, {{ user.adress.cty }} compiles fine and explodes at runtime. Cursor's older training data includes projects where this is off, so you need to pin it on and code accordingly — no implicit any in $event, no $any() escape hatches, no as casts in templates.

The rule:

tsconfig.json has `angularCompilerOptions.strictTemplates: true` plus
`strictNullChecks`, `strictInputAccessModifiers`, `strictInputTypes`,
`strictOutputEventTypes`, and `strictAttributeTypes`.

$event is typed — `(click)="save($event)"` with `save(e: MouseEvent)`,
`(input)="onInput($event.target)"` uses a typed handler, not `any`.

`$any()` is forbidden. `[attr.data-x]="something as any"` is forbidden.
If the template can't express the type, fix the component, not the
template.

Template control flow uses @if with `as` for narrowing, @for with
`track`, and @switch with typed discriminants. Replace legacy
*ngIf / *ngFor / ngSwitch everywhere.

Pipes used in templates are type-generic where relevant: `KeyValuePipe`,
`AsyncPipe`, custom `PipeTransform` implementations return a known
type, not `any`.
Enter fullscreen mode Exit fullscreen mode

Before — untyped $event, $any to silence the compiler, *ngFor with no track:

<input (input)="query = $any($event.target).value" />
<div *ngIf="user">
  <p>{{ user.adress.cty }}</p>
</div>
<li *ngFor="let item of items">{{ item.name }}</li>
Enter fullscreen mode Exit fullscreen mode

user.adress.cty compiles because the template typecheck is off. $any hides the fact the handler is taking any.

After — typed handlers, narrowed @if, tracked @for:

query = signal('');

onQuery(e: Event) {
  const input = e.target as HTMLInputElement;
  this.query.set(input.value);
}
Enter fullscreen mode Exit fullscreen mode
<input (input)="onQuery($event)" />
@if (user(); as u) {
  <p>{{ u.address.city }}</p>
}
@for (item of items(); track item.id) {
  <li>{{ item.name }}</li>
}
Enter fullscreen mode Exit fullscreen mode

Typo adress.cty fails compilation. @for reuses DOM nodes by stable identity. No $any.

Rule 7: Lazy Loading — Routes and Components, Not Just Feature Modules

The app-routing.module.ts that imports every feature module at the top and calls loadChildren with a module path is eight years of tribal knowledge you no longer need. loadComponent + standalone components lets you split per-route at the component level, preload with a strategy, and defer expensive widgets with @defer blocks in templates. Cursor still generates the module-based variant by default.

The rule:

Route configs use `loadComponent: () => import(...).then(m =>
m.MyComponent)` for standalone components. `loadChildren` only for
legacy module-based features.

Provide route-scoped services with `providers: [...]` on the route
config, not on the component — they're created once per navigation,
destroyed on route exit.

Use `withPreloading(PreloadAllModules)` or a custom
`PreloadingStrategy` during idle. Never preload on slow
connections — check `navigator.connection` in the strategy.

Heavy leaf widgets (charts, editors, analytics) use `@defer` blocks
with explicit triggers (on viewport, on interaction, on hover, on
timer). Always provide `@placeholder` and `@loading`.

Chunk names come from Vite/esbuild via `/* webpackChunkName */` or
route `data: { chunk: '...' }` — don't ship `chunk-abc123.js` to
production and wonder what broke.
Enter fullscreen mode Exit fullscreen mode

Before — module-based lazy routes, eager import of a huge chart library:

const routes: Routes = [
  { path: 'dashboard', loadChildren: () =>
      import('./dashboard/dashboard.module').then(m => m.DashboardModule) },
];

@Component({
  selector: 'app-report',
  template: `<app-heavy-chart [data]="data"></app-heavy-chart>`,
})
export class ReportComponent { /* chart pulls in ~800kb */ }
Enter fullscreen mode Exit fullscreen mode

After — component-level lazy loading, deferred chart, route-scoped provider:

export const routes: Route[] = [
  {
    path: 'dashboard',
    loadComponent: () => import('./dashboard/dashboard').then(m => m.Dashboard),
    providers: [provideDashboardApi()],
  },
];

@Component({
  selector: 'app-report',
  standalone: true,
  imports: [HeavyChartComponent],
  template: `
    @defer (on viewport) {
      <app-heavy-chart [data]="data()" />
    } @placeholder {
      <div class="chart-skeleton"></div>
    } @loading (minimum 200ms) {
      <app-spinner />
    }
  `,
})
export class ReportComponent {
  data = input.required<ChartData>();
}
Enter fullscreen mode Exit fullscreen mode

Chart is downloaded only when the user scrolls to it. Dashboard feature's API provider is created once per navigation.

Rule 8: Testing With TestBed — Component Harnesses, No Template Assertions on Strings

Cursor's default Angular tests are fixture.nativeElement.innerHTML.includes(...) and fixture.detectChanges(); expect(fixture.componentInstance.loading).toBe(true); — implementation tests that break on every refactor and catch no real bugs. Modern Angular tests use Component Test Harnesses (from @angular/cdk/testing) to interact with a component the way a user would, and TestBed in zone-less or waitForAsync contexts for async flows.

The rule:

TestBed with `configureTestingModule({ imports: [StandaloneComponent] })`
— never declare; standalone goes in imports.

Use HarnessLoader + ComponentHarness from @angular/cdk/testing instead
of fixture.nativeElement.querySelector for anything interactive.
Never assert on innerHTML or classList — assert on harness queries.

Mock at the HTTP boundary with HttpTestingController. Never mock a
service the component calls by replacing it with a stub class unless
the service has heavy non-HTTP side effects.

Async: `await fixture.whenStable()`, `fakeAsync + tick()`, or
`TestBed.inject(ApplicationRef).whenStable()`. Never `setTimeout` in
tests.

Zoneless tests (Angular 18+): `provideZonelessChangeDetection()` in
providers; `fixture.whenStable()` awaits all pending microtasks.

Component instance inspection (fixture.componentInstance.x) is forbidden
for anything that maps to a DOM-observable behavior. Test behavior,
not state.
Enter fullscreen mode Exit fullscreen mode

Before — querySelector on innerHTML, direct state inspection, synchronous assertion on async HTTP:

it('loads user', () => {
  const fixture = TestBed.createComponent(ProfileComponent);
  fixture.detectChanges();
  expect(fixture.componentInstance.loading).toBe(true);
  expect(fixture.nativeElement.innerHTML).toContain('Loading...');
});
Enter fullscreen mode Exit fullscreen mode

No actual HTTP verification. Breaks if the spinner text changes.

After — harness, HttpTestingController, behavior assertion:

describe('ProfileComponent', () => {
  let httpMock: HttpTestingController;
  let loader: HarnessLoader;
  let fixture: ComponentFixture<ProfileComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [ProfileComponent],
      providers: [
        provideHttpClient(),
        provideHttpClientTesting(),
        provideZonelessChangeDetection(),
      ],
    }).compileComponents();
    httpMock = TestBed.inject(HttpTestingController);
    fixture = TestBed.createComponent(ProfileComponent);
    loader = TestbedHarnessEnvironment.loader(fixture);
  });

  afterEach(() => httpMock.verify());

  it('shows the user name after HTTP resolves and saves edits', async () => {
    fixture.componentRef.setInput('userId', '42');
    await fixture.whenStable();

    httpMock.expectOne('/api/users/42').flush({
      id: '42', name: 'Ada Lovelace', email: 'ada@ex.com',
    });
    await fixture.whenStable();

    const heading = await loader.getHarness(MatHeadingHarness);
    expect(await heading.getText()).toContain('Ada Lovelace');

    const nameInput = await loader.getHarness(
      MatInputHarness.with({ selector: '[formControlName="name"]' }),
    );
    await nameInput.setValue('Grace Hopper');

    const saveBtn = await loader.getHarness(
      MatButtonHarness.with({ text: /save/i }),
    );
    await saveBtn.click();

    const req = httpMock.expectOne('/api/users/42');
    expect(req.request.method).toBe('PUT');
    expect(req.request.body).toEqual({ name: 'Grace Hopper' });
    req.flush({ id: '42', name: 'Grace Hopper', email: 'ada@ex.com' });
  });
});
Enter fullscreen mode Exit fullscreen mode

Reads like user behavior. Survives template refactors. Catches the real contract — the PUT body.

The Complete .cursorrules File

Drop this in the repo root. Cursor and Claude Code both pick it up.

# Angular — Production Patterns

## Strict Typing & Forms
- tsconfig strict: true, strictTemplates: true, strictNullChecks,
  strictInput/Output/AttributeTypes. No `any`; external data `unknown`
  then narrowed.
- Component state via signal<T>(), computed(), input(), model().
  No BehaviorSubject as a local store, no mutated class fields for
  binding.
- Reactive forms: NonNullableFormBuilder, typed FormGroup / FormControl<T>.
  Never raw `new FormGroup({...})`, never `as FormGroup`, never
  `form.get('x').value` without a generic.
- ValidatorFn / AsyncValidatorFn typed. Custom validators return
  ValidationErrors | null.

## Standalone Over NgModules
- All new components/directives/pipes `standalone: true`. No new
  NgModules.
- imports: [] lists only what the template uses. Built-in control flow
  (@if/@for/@switch) over *ngIf/*ngFor/ngSwitch.
- bootstrapApplication() with provideRouter, provideHttpClient(withFetch()),
  provideAnimationsAsync(). No AppModule.

## OnPush Change Detection
- Every component: changeDetection: ChangeDetectionStrategy.OnPush.
- Prefer signal inputs (input/input.required/model) over @Input().
- Template subscriptions via `async` pipe or toSignal(). Never
  subscribe-and-assign.
- Manual triggers use markForCheck(), never detectChanges().

## Dependency Injection
- `inject(Token)` at field declaration. No constructor DI for new code.
- inject(DestroyRef).onDestroy(...) / takeUntilDestroyed() for teardown.
  No manual OnDestroy + Subscription arrays.
- Services providedIn: 'root' unless scoped; feature DI via provideX()
  helpers.

## RxJS Discipline
- One subscribe per chain, at the edge (async pipe / toSignal / effect).
  Never subscribe-inside-subscribe — use switchMap / mergeMap / concatMap
  / exhaustMap.
- takeUntilDestroyed() on every component/service pipe that subscribes
  manually.
- Side effects in tap(), never map(). Hot streams shared with
  shareReplay({ bufferSize: 1, refCount: true }).

## Template Type Checking
- strictTemplates on. $event typed. No `$any()`. No template-level `as any`.
- @if / @for (with track) / @switch only. *ngIf/*ngFor removed.
- Custom pipes: PipeTransform with a typed return.

## Lazy Loading
- Route config uses loadComponent for standalone. loadChildren only for
  legacy modules.
- Route-scoped providers live on the route config, not the component.
- Heavy leaf widgets use @defer with triggers (viewport/interaction/
  hover/timer), always with @placeholder and @loading.
- Preloading strategy aware of navigator.connection.

## Testing
- TestBed.configureTestingModule({ imports: [StandaloneComponent] }).
  Never declare standalone.
- @angular/cdk/testing HarnessLoader + ComponentHarness for interaction.
  No innerHTML / classList assertions.
- HttpTestingController at the HTTP boundary. Don't stub services.
- Async via whenStable / fakeAsync+tick. No setTimeout.
- Behavior over state: never assert on componentInstance fields that
  have a DOM projection.
Enter fullscreen mode Exit fullscreen mode

End-to-End Example: A Search Box Filtering a List of Users

Without rules: NgModule, default CD, nested subscribes, untyped form, template *ngFor with index track.

@Component({
  selector: 'app-user-search',
  template: `
    <input [formControl]="q" />
    <div *ngFor="let u of users; let i = index; trackBy: trackByIndex">
      {{ u.name }}
    </div>
  `,
})
export class UserSearchComponent implements OnInit, OnDestroy {
  q = new FormControl('');
  users: any[] = [];
  private sub = new Subscription();

  constructor(private api: UserApi) {}

  ngOnInit() {
    this.sub.add(
      this.q.valueChanges.subscribe(v => {
        this.api.search(v!).subscribe(res => (this.users = res));
      }),
    );
  }

  trackByIndex(i: number) { return i; }
  ngOnDestroy() { this.sub.unsubscribe(); }
}
Enter fullscreen mode Exit fullscreen mode

With rules: standalone, OnPush, signals from typed form, switchMap, stable track.

@Component({
  selector: 'app-user-search',
  standalone: true,
  imports: [ReactiveFormsModule],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <input [formControl]="q" aria-label="Search users" />
    @for (u of users(); track u.id) {
      <div>{{ u.name }}</div>
    } @empty {
      <div>No results</div>
    }
  `,
})
export class UserSearchComponent {
  private api = inject(UserApi);
  q = new FormControl('', { nonNullable: true });

  users = toSignal(
    this.q.valueChanges.pipe(
      debounceTime(200),
      distinctUntilChanged(),
      switchMap(v => this.api.search(v)),
      takeUntilDestroyed(),
    ),
    { initialValue: [] as User[] },
  );
}
Enter fullscreen mode Exit fullscreen mode

Get the Full Pack

These eight rules cover the Angular patterns where AI assistants consistently reach for the wrong idiom. Drop them into .cursorrules and the next prompt you write will look different — signal-driven, standalone, OnPush-clean, inject-DI'd, RxJS-disciplined, template-typed, lazy-loaded, harness-tested Angular, without having to re-prompt.

If you want the expanded pack — these eight plus rules for Nx monorepos, server-side rendering with Angular Universal, resolvers and guards, the modern Control Value Accessor pattern, signal-based component libraries, and the testing conventions I use on production Angular apps — it is bundled in Cursor Rules Pack v2 ($27, one payment, lifetime updates). Drop it in your repo, stop fighting your AI, ship Angular you would actually merge.

Top comments (0)