Modernizing Control Flow
If your Angular project relies on the traditional structural directives such as *ngIf, *ngFor, and ngSwitch, there’s an opportunity to upgrade your templates with the sleeker and more performant syntax of @if, @for, and @switch.
Execution command:
ng g @angular/core:control-flow
Introduced in: Angular 17
Running this command presents you with the following configuration questions:
- Migration path: The tool asks which directory should be processed. The default setting
./targets the whole project. To limit changes to a specific folder, supply a custom relative path. - Template reformatting: Responding 'Y' directs the generator to re-indent your HTML automatically after the migration, making the output cleaner. Choosing 'n' opts out of any formatting changes.
Reformatting your templates generally improves code readability and is a sensible habit, but the decision remains yours.
Format Template source code: https://github.com/angular/angular/blob/main/packages/core/schematics/ng-generate/control-flow-migration/migration.ts#L69
Illustrative Examples
Conditional logic: Prior state
<div *ngIf="isVisible; else elseBlock">
Displayed when isVisible is true
</div>
<ng-template #elseBlock>
<div>
Displayed when isVisible is false
</div>
</ng-template>
Conditional logic: Post-migration
@if (isVisible) {
<div>Displayed when isVisible is true</div>
} @else {
<div>Displayed when isVisible is false</div>
}
———————–
Case-based logic: Prior state
<div [ngSwitch]="color">
<div *ngSwitchCase="'red'">Red color selected</div>
<div *ngSwitchCase="'blue'">Blue color selected</div>
<div *ngSwitchCase="'green'">Green color selected</div>
<div *ngSwitchDefault>Other color selected</div>
</div>
Case-based logic: Post-migration
@switch (color) {
@case ('red') {
<div>Red color selected</div>
}
@case ('blue') {
<div>Blue color selected</div>
}
@case ('green') {
<div>Green color selected</div>
}
@default {
<div>Other color selected</div>
}
}
———————–
Loop block: Prior state
<ng-container *ngIf="items.length; else emptyList">
<div *ngFor="let item of items">
{{ item }}
</div>
</ng-container>
<ng-template #emptyList>
The list is empty
</ng-template>
Loop block: Post-migration
@if (items.length) {
@for (item of items; track item) {
<div>
{{ item }}
</div>
}
} @else {
The list is empty
}
Post-migration, you can further polish the generated output. For instance, converting to the @empty block enhances the code's clarity.
Loop block: Optimized version
@for (item of items; track item) {
<div>
{{ item }}
</div>
} @empty {
The list is empty
}
Adopting the Inject Function
The inject function offers notable benefits when compared to conventional constructor-based dependency injection.
Key advantages include:
- Heightened type safety: The
injectfunction provides more accurate type inference for the dependencies it resolves. - Enhanced reusability: This approach simplifies the creation of utility functions that leverage injected services.
- Simpler inheritance: With
inject, classes in an inheritance chain no longer need to forward dependencies through their parent's constructor. - Future-ready code: Aligning with the
injectfunction adheres to Angular's modern development standards.
Execution command:
ng g @angular/core:inject
Introduced in: Angular 18
Running this command presents you with the following configuration questions:
- Migration scope: The default
./path processes the entire application. A specific relative path can be provided to target a subset of your project. - Migrating abstract classes: These are skipped by default since their constructor parameters might not be suitable for direct injection. Opting in could lead to subtle compilation issues, so exercise caution.
To grasp this, review the following class structure:
base-http.service.ts
export abstract class BaseHttpService {
abstract endpoint: string;
abstract baseUrl: string;
constructor(private http: HttpClient) {}
private getFullUrl(): string {
return `${this.baseUrl}/${this.endpoint}`;
}
get<T>(params?: HttpParams, headers?: HttpHeaders): Observable<T> {
return this.http.get<T>(this.getFullUrl(), { params, headers });
}
}
products.service.ts
export class ProductsService extends BaseHttpService {
endpoint: string = 'products';
baseUrl: string = 'https://api.com';
constructor(http: HttpClient) {
super(http);
}
}
The BaseHttpService obtains its HttpClient dependency via the constructor. Because ProductsService inherits from it, the subclass must pass HttpClient up to the superclass constructor.
The generator processes files in isolation and cannot fully understand cross-class relationships. While processing BaseHttpService, it will:
- Identify the
HttpClientbeing injected through the constructor. - Replace it with the
inject(HttpClient)call. - Strip the constructor parameters, as they are no longer needed.
This transformation can break code such as ProductsService, which depends on sending HttpClient to its parent.
Opting to migrate abstract classes may trigger these compilation errors. Interestingly, these errors can serve as useful markers to identify and fix problematic patterns.
Constructor cleanup and backward compatibility: The default behavior removes all constructor arguments, and empty constructors are removed entirely. If you enable this option, it will preserve a catch-all constructor signature, constructor(…args: unknown[]);, avoiding breakage in subclasses at the cost of extra boilerplate code.
Here's what BaseHttpService looks like with backward compatibility enabled:
export abstract class BaseHttpService {
private http = inject(HttpClient);
abstract endpoint: string;
abstract baseUrl: string;
/** Inserted by Angular inject() migration for backwards compatibility */
constructor(...args: unknown[]);
constructor() {}
private getFullUrl(): string {
return `${this.baseUrl}/${this.endpoint}`;
}
get<T>(params?: HttpParams, headers?: HttpHeaders): Observable<T> {
return this.http.get<T>(this.getFullUrl(), { params, headers });
}
}
Notice that the generator has introduced both the original and a fallback constructor to maintain compatibility.
While this avoids compilation issues, it does add extra lines to your source.
Non-nullable inject results: Normally, the generator will produce a null union type for optional dependencies that use @Optional(). However, since decorators can't directly modify the inferred TypeScript type, your code may incorrectly assume such dependencies are always available.
export class AppComponent {
constructor(@Inject(MY_TOKEN) @Optional() private readonly token: MyToken) {}
private tokenHandler() {
console.log(this.token.claims);
}
}
The tokenHandler function attempts to read the claims property. Yet, because MY_TOKEN is declared as optional, this.token might be null. In that scenario, accessing .claims causes a runtime error.
To avoid this issue, the token property should be strictly typed as `token: MyToken | null`. This will force a compilation error if you try to access properties on a nullable token, prompting you to add a truthiness check before proceeding.
private tokenHandler() {
if (this.token) {
console.log(this.token.claims);
}
}
The Angular team has programmed the generator to handle this typical oversight.
If you confirm this prompt, the resulting code will be:
private readonly token = inject<MyToken>(MY_TOKEN, { optional: true })!;
Observe the exclamation mark following `token`. It signals a non-nullable type and allows the code to compile without errors.
Implementing Lazy Loading for Routes
By adopting lazy loading for routes, the build output is divided into smaller, separate chunks. This strategy results in a quicker initial page experience for users.
Execution command:
ng g @angular/core:route-lazy-loading
Introduced in: Angular 18
Running this command presents you with the following configuration question:
- Migration scope: The default
./path processes the entire application. A specific relative path can be provided to target a subset of your project.
Illustrative Examples
provideRouter: Prior state
export const appConfig: ApplicationConfig = {
providers: [
provideRouter([
{
path: 'products',
component: ProductsComponent,
},
]),
],
};
provideRouter: Post-migration
export const appConfig: ApplicationConfig = {
providers: [
provideRouter([
{
path: 'products',
loadComponent: () =>
import('./pages/products/products.component').then(
(m) => m.ProductsComponent,
),
},
]),
],
};
———————–
It also correctly processes components declared as default.
provideRouter: Prior state
export const appConfig: ApplicationConfig = {
providers: [
provideRouter([
{
path: 'products',
component: ProductsComponent,
},
]),
],
};
provideRouter: Post-migration
export const appConfig: ApplicationConfig = {
providers: [
provideRouter([
{
path: 'products',
loadComponent: () => import('./pages/products/products.component'),
},
]),
],
};
The schematic also extends its transformation to RouterModule configurations. The resulting code remains based on RouterModule, but the earlier eagerly loaded routes are now switched to lazy ones. For the best results, it's strongly suggested to first move to a standalone application and then execute this migration again.
Signal Inputs
This schematic converts the Input decorator to signal inputs.
Command:
ng g @angular/core:signal-input-migration
Available from: Angular 19
Running this command triggers the following prompts:
- ✔ Which path in your project should be migrated?
The default value is ./, which covers the entire application. A relative path can be supplied to restrict the migration to a specific portion of the project.
- ✔ Do you want to migrate as much as possible, even if it may break your build?
Component inputs form a critical part of the component API and should be handled as the definitive source of truth.
- Traditional Inputs: In the past, inputs declared with the @Input() decorator could be mutated inside the component, which sometimes caused unpredictable outcomes.
- Signal Inputs: The Signal Input API enforces a recommended practice: input values are meant to be treated as immutable within the component.
Answering 'yes’ to this prompt means every input in the project moves to the Signal Input API, no matter how those inputs are currently being used. This could necessitate changes in your component logic if you were previously modifying input values directly inside the component.
Code Examples
Take this component as an illustration to see how the code changes when we respond 'n' (No) to the second prompt.
Before
@Component({
selector: 'app-user-card',
imports: [JsonPipe],
template: `
<p>{{ user | json }}</p>
<p>{{ isEnabled }}</p>
<p>{{ isEdit }}</p>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserCardComponent {
@Input() user: User | undefined = undefined;
@Input({
required: true,
transform: booleanAttribute,
})
isEnabled: boolean = false;
@Input() isEdit: boolean = false;
methodThatEditsTheInput() {
this.isEdit = false;
}
}
The migrated code is expected to:
- include the correct default value on the user input
- set isEnabled as required, preserving its boolean transformation
- leave isEdit unchanged, since its value is updated through a method
- reflect the correct usage in the HTML template
After
@Component({
selector: 'app-user-card',
imports: [JsonPipe],
template: `
<p>{{ user() | json }}</p>
<p>{{ isEnabled() }}</p>
<p>{{ isEdit }}</p>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserCardComponent {
readonly user = input<User>();
readonly isEnabled = input.required<boolean, unknown>({ transform: booleanAttribute });
@Input() isEdit: boolean = false;
methodThatEditsTheInput() {
this.isEdit = false;
}
}
Signal inputs always carry a value, with undefined as the default. Therefore, there is no need to explicitly assign undefined to the user input.
Now let’s look at the two generic types on the isEnabled input. The first one (`boolean`) refers to the actual type of the input, meaning isEnabled is of type boolean.
The second generic type corresponds to the type of the value being provided. Because the type here is boolean, that value could be the string "true" or "false". Swapping `unknown` for `string` would certainly be appropriate. However, Angular at migration time conservatively uses `unknown` since the exact type is not yet determined and will ultimately be resolved through type assertions or type guards.
Let’s now examine what the code looks like when we answer "Y" (Yes) to the prompt.
@Component({
selector: 'app-user-card',
imports: [JsonPipe],
template: `
<p>{{ user() | json }}</p>
<p>{{ isEnabled() }}</p>
<p>{{ isEdit() }}</p>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserCardComponent {
readonly user = input<User>();
readonly isEnabled = input.required<boolean, unknown>({ transform: booleanAttribute });
readonly isEdit = input<boolean>(false);
methodThatEditsTheInput() {
this.isEdit = false;
}
}
The isEdit input was migrated, which introduced a compilation error in the methodThatEditsTheInput method.
A good approach is to answer "n" (No) along with the option –insert-todos
–insert-todos
The –insert-todos option places TODO comments in the code for any occurrences that could not be migrated.
ng g @angular/core:signal-input-migration --insert-todos
@Component({
selector: 'app-user-card',
imports: [JsonPipe],
template: `
<p>{{ user() | json }}</p>
<p>isEnabled: {{ isEnabled() }}</p>
<p>{{ isEdit }}</p>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserCardComponent {
readonly user = input<User>();
readonly isEnabled = input.required<boolean, unknown>({
transform: booleanAttribute,
});
// TODO: Skipped for migration because:
// Your application code writes to the input. This prevents migration.
@Input() isEdit: boolean = false;
methodThatEditsTheInput() {
this.isEdit = false;
}
}
Outputs
This migration replaces Output decorators with the output function.
Command:
ng g @angular/core:output-migration
Available from: Angular 19
Executing this command brings up the following prompts:
- ✔ Which path in your project should be migrated?
The default value is ./, which applies to the whole application. A relative path can be given to target only a particular section.
Code Examples
Before
export class UserCardComponent {
@Output('userChanged') userChange = new EventEmitter();
methodThatDoesSomething() {
this.userChange.emit();
}
}
After
export class UserCardComponent {
readonly userChange = output({ alias: 'userChanged' });
methodThatDoesSomething() {
this.userChange.emit();
}
}
Signal Queries
This migration transforms the @ViewChild @ViewChildren @ContentChild, and @ContentChildren decorators into the respective viewChild, viewChildren, contentChild, and contentChildren signal queries.
Command:
ng g @angular/core:signal-queries-migration
Available from: Angular 19
When run, this migration presents the following prompts:
- ✔ Which path in your project should be migrated?
The default value is ./, covering the full application. A relative path allows migration of a smaller part of the project.
- ✔ Do you want to migrate as much as possible, even if it may break your build?
Since the schematic aims to avoid introducing compilation errors during migration, answering “n” (No) will skip occurrences that could fail or generate compilation issues. Some cases are:
- When the expression is used in a control flow context such as (*ngIf, @if), Angular is unable to properly narrow the type.
Example
- When the expression is used in a control flow context such as (*ngIf, @if), Angular is unable to properly narrow the type.
@Component({
selector: 'app-user-card',
imports: [NgIf, NgTemplateOutlet],
template: `
<ng-container *ngIf="cardContentTemplate">
<ng-templateOutlet [ngTemplateOutlet]="cardContentTemplate" />
</ng-container>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserCardComponent {
@ContentChild('cardContent', { read: TemplateRef }) cardContentTemplate:
| TemplateRef<any>
| undefined;
}
- When the query is combined with @HostBinding, the migration will not succeed.
Example
@HostBinding('class.my-custom-class')
@ContentChild('cardContent', { read: TemplateRef })
cardContentTemplate: TemplateRef<any> | undefined;
- When a setter is employed to process the query value
Example
@ContentChild('cardContent', { read: TemplateRef })
set cardContentTemplateAsSet(value: TemplateRef<any> | undefined) {
console.log('cardContentTemplateAsSet', value);
}
Selecting yes means the migration proceeds, but be aware that you’ll need to manually verify the application functions properly as errors could exist.
To play it safe, you can respond "n" (No) and include the option –insert-todos.
ng g @angular/core:signal-queries-migration --insert-todos
With this option in place, the migrator inserts TODO comments where migration failed.
Let’s walk through some examples of the resulting migrated code.
Code Examples
viewChild & viewChildren Before
@ViewChild(UserCardComponent)
userCard!: UserCardComponent;
@ViewChildren(UserCardComponent)
userCards!: QueryList<UserCardComponent>;
viewChild & viewChildren After
readonly userCard = viewChild.required(UserCardComponent);
readonly userCards = viewChildren(UserCardComponent);
—
contentChild Before
@HostBinding('class.my-custom-class')
@ContentChild('cardContent', { read: TemplateRef })
cardContentTemplateWithHostBinding: TemplateRef<any> | undefined;
@ContentChild('cardContent', { read: TemplateRef })
set cardContentTemplateAsSet(value: TemplateRef<any> | undefined) {
console.log('cardContentTemplateAsSet', value);
}
@ContentChild('cardContent', { read: TemplateRef })
cardContentTemplateUsedWithIfCondition: TemplateRef<any> | undefined;
@ContentChild('cardContent', { read: TemplateRef })
cardContentTemplate!: TemplateRef<any>;
contentChild After
// TODO: Skipped for migration because:
// This query is used in combination with `@HostBinding` and migrating would break.
@HostBinding('class.my-custom-class')
@ContentChild('cardContent', { read: TemplateRef })
cardContentTemplateWithHostBinding: TemplateRef<any> | undefined;
// TODO: Skipped for migration because:
// Accessor queries cannot be migrated as they are too complex.
@ContentChild('cardContent', { read: TemplateRef })
set cardContentTemplateAsSet(value: TemplateRef<any> | undefined) {
console.log('cardContentTemplateAsSet', value);
}
// TODO: Skipped for migration because:
// This query is used in a control flow expression (e.g. `@if` or `*ngIf`)
// and migrating would break narrowing currently.
@ContentChild('cardContent', { read: TemplateRef })
cardContentTemplateUsedWithIfCondition: TemplateRef<any> | undefined;
readonly cardContentTemplate = contentChild.required('cardContent', { read: TemplateRef });
The Angular team continues to do excellent work, regularly introducing new features and simplifying the process of keeping projects current. For more details on available migration tools, check this page: https://next.angular.dev/reference/migrations. This resource lists all current migrations plus any that are added in the future.
Thanks for reading!!
