angular-idioms
SkillDev toolsAngular components, signals, DI, RxJS, standalone architecture. For TypeScript see typescript-idioms.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the angular-idioms skill
What this skill tells your AI
The instructions your AI receives, as published by irahardianto/awesome-agv in .agents/skills/angular-idioms/SKILL.md and read by ahel’s review.
Angular Idioms and Patterns
Core Philosophy
Angular (19+) rewards signals, standalone components, and reactive patterns. Idiomatic Angular = typed, modular, RxJS-aware, OnPush by default.
Scope: This file covers Angular-specific coding idioms for components, services, and patterns. For TypeScript type system patterns, see
@.agents/skills/typescript-idioms/SKILL.md. For file and folder layout, seereferences/project-structure.md.Loading guard: Do NOT load this skill for non-Angular projects. Vue →
vue-idioms; React →react-idioms; Next.js →nextjs-idioms. Angular file suffixes (.component.ts,.service.ts, etc.) andangular.jsonare the reliable triggers —tsconfig*.jsonalone is NOT an Angular signal (every TS project has one).
When to Load References
Load these before writing code in the matching context — not after.
| Situation | Reference to Load |
|---|---|
| Starting an Angular project or reviewing file layout | references/project-structure.md |
| TypeScript type system, async, Zod, error types | @.agents/skills/typescript-idioms/SKILL.md (always co-load) |
| Zod schemas / boundary validation | @.agents/skills/typescript-idioms/references/zod-patterns.md |
| Async / I/O / coercion / RxJS pitfalls | @.agents/skills/typescript-idioms/references/ts-patterns-and-anti-patterns.md |
Standalone Components (Default)
-
Standalone components — no NgModules for new components (standalone is the default since Angular 19):
@Component({ selector: 'app-task-list', changeDetection: ChangeDetectionStrategy.OnPush, imports: [TaskCardComponent], template: ` @for (task of filteredTasks(); track task.id) { <app-task-card [task]="task" /> } ` }) export class TaskListComponent { tasks = signal<Task[]>([]); } -
Lazy-load routes with
loadComponent:{ path: 'tasks', loadComponent: () => import('./features/task/task-list.component') .then(m => m.TaskListComponent) }
Signals (17+)
- Signals for synchronous state — prefer over BehaviorSubject for component state.
computedfor derived state.effectfor side effects.- RxJS for async streams — HTTP, WebSocket, complex event handling.
// ✅ Signal-based component state
export class TaskListComponent {
private readonly taskService = inject(TaskService);
tasks = signal<Task[]>([]);
filter = signal<string>('');
filteredTasks = computed(() => // ✅ Derived state
this.tasks().filter(t => t.title.includes(this.filter()))
);
constructor() {
effect(() => console.debug('Tasks updated:', this.tasks().length)); // ✅ Side effect
}
}
Change Detection
-
OnPushis the default strategy — set it on every component:// ✅ Always OnPush @Component({ changeDetection: ChangeDetectionStrategy.OnPush, // ... }) -
OnPush works naturally with signals — signal reads in templates automatically trigger change detection when the signal value changes.
-
Use
Defaultonly when wrapping third-party components that mutate state imperatively and cannot be refactored. -
Never call
ChangeDetectorRef.detectChanges()manually — if you need it, your data flow is wrong. Convert to signals or useasyncpipe.
Component Design
-
Signal-based inputs and outputs (Angular 17.1+) — prefer over decorators:
// ✅ Signal input — reactive, no OnChanges needed task = input.required<Task>(); variant = input<'compact' | 'full'>('full'); // ✅ Signal output taskCompleted = output<string>(); // ❌ Decorator style — legacy @Input() task!: Task; @Output() taskCompleted = new EventEmitter<string>(); -
Content projection — use
<ng-content>for composable UI,selectattribute for named slots:@Component({ template: ` <header><ng-content select="[card-header]" /></header> <main><ng-content /></main> ` }) -
Signal-based view queries (Angular 17.2+):
canvas = viewChild.required<ElementRef>('canvas'); // ✅ Reactive, no AfterViewInit items = contentChildren(TabItemComponent); // ✅ Content query -
Lifecycle hooks guidance:
OnInit— fetch initial data, set up subscriptions (useinject(DestroyRef)for cleanup)OnDestroy— manual cleanup only iftakeUntilDestroyedorDestroyRefcannot be used- Avoid
OnChanges— use signal inputs withcomputed()oreffect()instead
Template Patterns
-
Use new control flow syntax (Angular 17+) —
@for,@if,@switch,@defer:<!-- ✅ New control flow with track expression --> @for (task of tasks(); track task.id) { <app-task-card [task]="task" /> } @empty { <p>No tasks found.</p> } @if (isLoading()) { <app-spinner /> } @else { <app-task-list [tasks]="tasks()" /> } @switch (task().priority) { @case ('high') { <span class="badge-high">High</span> } @case ('medium') { <span class="badge-med">Medium</span> } @default { <span class="badge-low">Low</span> } } -
@deferfor lazy-loaded template blocks:<!-- ✅ Lazy-load heavy component --> @defer (on viewport) { <app-task-analytics [tasks]="tasks()" /> } @placeholder { <div class="skeleton" /> } -
Use
ng-containerfor grouping without extra DOM nodes — e.g.,<ng-container *ngTemplateOutlet="tpl" />. -
Avoid complex expressions in templates — extract to
computed():// ❌ template: `{{ tasks().filter(t => t.done).length }} / {{ tasks().length }}` // ✅ Extract to computed summary = computed(() => `${this.doneTasks().length} / ${this.tasks().length}`);
Dependency Injection
inject()function over constructor injection in standalone components.- Provide at appropriate level — component, route, or root.
- Abstract services behind interfaces for testability:
// ✅ Abstract class as interface (TypeScript has no runtime interfaces)
export abstract class TaskStorage {
abstract getById(id: string): Observable<Task>;
abstract save(task: Task): Observable<void>;
}
// ✅ Implementation
@Injectable()
export class HttpTaskStorage extends TaskStorage {
private readonly http = inject(HttpClient);
getById(id: string) { return this.http.get<Task>(`/api/tasks/${id}`); }
save(task: Task) { return this.http.post<void>('/api/tasks', task); }
}
// ✅ Wired at route or root level
providers: [{ provide: TaskStorage, useClass: HttpTaskStorage }]
Reactive Forms
- Reactive forms over template-driven for complex forms.
- Typed forms (
FormControl<string>) — always. - Custom validators as pure functions:
// ✅ Typed form group with nonNullable controls
export class TaskFormComponent {
form = new FormGroup({
title: new FormControl('', { nonNullable: true,
validators: [Validators.required, Validators.maxLength(200)] }),
priority: new FormControl<'low' | 'medium' | 'high'>('medium', { nonNullable: true }),
});
}
Routing Patterns
-
Functional guards (Angular 15+) — no class-based guards:
// ✅ Functional guard export const authGuard: CanActivateFn = (route, state) => { const auth = inject(AuthService); return auth.isAuthenticated() || inject(Router).createUrlTree(['/login']); }; // ❌ Class-based guard — deprecated @Injectable() export class AuthGuard implements CanActivate { ... } -
Functional resolvers — same pattern, use
ResolveFn<T>:export const taskResolver: ResolveFn<Task> = (route) => inject(TaskService).getById(route.paramMap.get('id')!); -
Lazy loading with
loadChildrenfor feature routes:{ path: 'tasks', loadChildren: () => import('./features/task/task.routes') .then(m => m.TASK_ROUTES), canActivate: [authGuard], } -
Route parameter binding with
input()(Angular 16+):// In app.config.ts: withComponentInputBinding() // ✅ Route params bound as signal inputs — no ActivatedRoute needed taskId = input.required<string>(); // ❌ Manual route param subscription this.route.paramMap.pipe(...).subscribe(...)
State Management
-
Component signals first — sufficient for most local UI state.
-
NgRx Signal Store for shared or complex state beyond a single component:
// ✅ NgRx Signal Store export const TaskStore = signalStore( { providedIn: 'root' }, withState<TaskState>({ tasks: [], isLoading: false, error: null }), withComputed(({ tasks }) => ({ completedTasks: computed(() => tasks().filter(t => t.done)), })), withMethods((store, taskService = inject(TaskService)) => ({ async loadTasks(): Promise<void> { patchState(store, { isLoading: true }); const tasks = await firstValueFrom(taskService.getAll()); patchState(store, { tasks, isLoading: false }); }, })), ); -
When to use what:
signal()— local component state, simple parent-child data flow- NgRx Signal Store — shared state across components, entity management (
withEntities()) - RxJS + services — real-time streams, WebSocket data, complex async orchestration
Error Handling
For universal error handling principles, see
.agents/rules/error-handling-principles.md. Below: Angular-specific patterns only.
-
Global error handler for uncaught exceptions:
@Injectable() export class GlobalErrorHandler implements ErrorHandler { handleError(error: unknown): void { this.logger.error('unhandled_error', { error }); // Log to observability platform } } -
HTTP interceptor for centralized error handling:
export const errorInterceptor: HttpInterceptorFn = (req, next) => next(req).pipe( catchError((error: HttpErrorResponse) => { if (error.status === 401) { // Redirect to login } return throwError(() => error); }) ); -
RxJS error handling — never leave Observables unhandled. Always use
catchErrorin.pipe()or handle insubscribe()error callback.
Anti-Patterns
- ❌ NgModules for new components — use standalone components (default since v19)
- ❌ BehaviorSubject for simple component state — use signals
- ❌ Constructor injection in standalone components — use
inject() - ❌ Manual subscriptions without cleanup — use
takeUntilDestroyed()orasyncpipe - ❌
anyin template bindings — type everything - ❌ Direct DOM manipulation — use Angular's renderer or signals
- ❌
subscribe()in components without unsubscribe — preferasyncpipe ortoSignal() - ❌
ChangeDetectionStrategy.Defaultwithout justification — alwaysOnPush - ❌
*ngFor/*ngIfin new code — use@for/@ifcontrol flow - ❌ Class-based guards and resolvers — use functional equivalents
// ❌ Memory leak — subscription never cleaned up
ngOnInit() {
this.taskService.getTasks().subscribe(tasks => this.tasks = tasks);
}
// ✅ Auto-cleanup with takeUntilDestroyed
private destroyRef = inject(DestroyRef);
ngOnInit() {
this.taskService.getTasks().pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(tasks => this.tasks.set(tasks));
}
// ✅ Even better — convert to signal
tasks = toSignal(this.taskService.getTasks(), { initialValue: [] });
Naming Conventions
-
File naming — dot-separated with type suffix:
task-list.component.ts,task.service.ts,task.pipe.ts,task.guard.ts,task.directive.tstask.routes.tsfor feature route definitionstask-list.component.spec.tsfor tests (co-located)
-
Selector prefixes — use
app-(or project-specific prefix fromangular.json):selector: 'app-task-card' -
Class naming — suffix matches file type:
TaskListComponent,TaskService,HighlightDirective,DateFormatPipe -
Route file exports —
UPPER_SNAKE_CASE:export const TASK_ROUTES: Routes = [...]
Testing
For universal testing principles, see
.agents/rules/testing-strategy.md. Below: Angular-specific patterns only. Test-file naming:*.spec.tsco-located (Angular CLI default —.component.spec.tsbeside.component.ts). For the cross-framework reconciliation rule, see@.agents/skills/typescript-idioms/references/project-structure.md§Test Organization.
-
Angular Testing Library for component tests (preferred over TestBed):
import { render, screen } from '@testing-library/angular'; it('should display task title', async () => { await render(TaskCardComponent, { componentInputs: { task: mockTask }, }); expect(screen.getByText('Deploy fix')).toBeInTheDocument(); }); -
Spectator for service tests:
const spectator = createServiceFactory({ service: TaskService, mocks: [TaskStorage], }); -
HttpTestingControllerfor HTTP service tests — no live backend. -
Signal Store testing — test store methods directly, assert signal values:
it('should load tasks', async () => { const store = TestBed.inject(TaskStore); await store.loadTasks(); expect(store.tasks().length).toBeGreaterThan(0); });
Formatting and Static Analysis
| Tool | Purpose | Command |
|---|---|---|
| Prettier | Formatting | npx prettier --write . |
| ESLint + angular-eslint | Linting | npx ng lint |
strict mode | Type checking | "strict": true in tsconfig.json |
| Angular compiler | Template checking | npx ng build (checks templates) |
Related
- Code Idioms and Conventions @.agents/rules/code-idioms-and-conventions.md
- TypeScript Idioms @.agents/skills/typescript-idioms/SKILL.md
- Angular Project Structure @.agents/skills/angular-idioms/references/project-structure.md
- Frontend Layout (framework-neutral, shared with React/Vue) @.agents/skills/frontend-design/references/frontend-layout.md
- Frontend Design @.agents/skills/frontend-design/SKILL.md
- Security Principles @.agents/rules/security-principles.md
- Accessibility Principles @.agents/rules/accessibility-principles.md
- Testing Strategy @.agents/rules/testing-strategy.md
- Error Handling Principles @.agents/rules/error-handling-principles.md
- Logging and Observability @.agents/rules/logging-and-observability-mandate.md
- Architectural Patterns @.agents/rules/architectural-pattern.md
Signals
- GitHub stars
- 156
- Forks
- 53
- Last commit
- Aug 2026
Advanced
- Catalog kind
- skill
- Gateway key
angular-idioms- Source
- github.com/irahardianto/awesome-agv