Mert Özen Further With Every Line
Frontend Angular — #21

What Angular 14 Brought: Typed Reactive Forms, inject() and a First Look at Standalone Components

Mert Özen Oct 31, 2026 8 min 2 views
What Angular 14 Brought: Typed Reactive Forms, inject() and a First Look at Standalone Components

Angular 14 made reactive forms typed: typed FormControl, nonNullable and NonNullableFormBuilder. Plus the inject() function, standalone components in developer preview and route titles.

Version note: This post covers what Angular 14 introduced; the code targets Angular 14.

The Version Where Changes Became Visible

Angular 13 mostly cleaned things up under the hood. Angular 14, released on June 2, 2022, came with changes that touch the code you write every day. Reactive forms finally got type safety, dependency injection got an alternative to the constructor, and the idea of a component without an NgModule made its first appearance.

In this post we will put these three side by side with their Angular 12 counterparts. At the end we will also take a quick look at route titles and the version's other small but useful additions. Let's get the upgrade out of the way first.

Moving from 13 to 14

The flow we saw earlier still applies: a clean commit, the right Node version and a single major step.

git commit -am "chore: state before Angular 14 update"
ng update @angular/core@14 @angular/cli@14

Angular 14's official Node range is 14.15 or later, or 16.10 or later. Node 12 is off the list; you can keep going with the Node 16 we have used throughout the series. On the TypeScript side you need at least 4.6.

The migrations do a bit more work this time. They replace the ~ in front of the Angular packages in package.json with ^, move the compilation target in tsconfig.json to ES2020 and remove the no longer used defaultProject setting from angular.json. Most importantly, there is a migration that prepares your reactive forms code for the new type system. We will come back to it shortly.

Reactive Forms and any in Angular 12

Remember the reactive forms we saw earlier. Form values had no types; everything came back as any:

export class ProfileFormComponent {
  form = new FormGroup({
    name: new FormControl(''),
    age: new FormControl(0)
  });

  save(): void {
    const value = this.form.value;          // any
    console.log(value.nmae.toUpperCase());  // compiles, fails at runtime
  }
}

We typed nmae instead of name and TypeScript said nothing. That is because form.value is any; as far as the compiler is concerned, it could have any property. You only find out when the user clicks save and the console shows a message about undefined. It is like moving house with unlabeled boxes: you do not know what is inside until you open one.

Typed Forms: The Form Knows Its Types

As of Angular 14, reactive forms are typed by default. The same form now behaves like this:

export class ProfileFormComponent {
  form = new FormGroup({
    name: new FormControl('', { nonNullable: true }),
    age: new FormControl<number | null>(null)
  });

  save(): void {
    console.log(this.form.value.nmae);  // compile error
    const raw = this.form.getRawValue(); // { name: string; age: number | null }
    console.log(raw.name.toUpperCase());
  }
}

Now the moment you type nmae, your editor underlines it in red. When Angular sees FormControl(''), it infers the type from the initial value. But note: that type is not string, it is string | null. The reason is reset(). By default a control that gets reset ends up with a value of null, and TypeScript does not hide that possibility from you.

The nonNullable: true option changes that behavior. A control like this returns to its initial value on reset instead of null, and its type is a plain string. This is more than a typing switch; it also changes runtime behavior, so keep that in mind when adding it to an existing form.

For age we wrote the type explicitly. For a control whose initial value is null, Angular would infer the type as just null, and a later setValue(30) would be a compile error. Writing FormControl<number | null> states our intent up front.

FormGroup, Partial and NonNullableFormBuilder

You may have wondered why I used getRawValue() above instead of form.value. Disabled controls in a FormGroup are left out of value. Angular reflects that in the type: form.value is typed as Partial<{ name: string; age: number | null }>, meaning every field may be undefined. If you want everything, disabled fields included, with exact types, use getRawValue().

In large forms, writing nonNullable: true on every control gets tiring. That is what NonNullableFormBuilder is for:

import { Component, inject } from '@angular/core';
import { NonNullableFormBuilder, Validators } from '@angular/forms';

@Component({
  selector: 'app-signup',
  templateUrl: './signup.component.html'
})
export class SignupComponent {
  private readonly fb = inject(NonNullableFormBuilder);

  form = this.fb.group({
    email: ['', [Validators.required, Validators.email]],
    password: ['', Validators.minLength(8)]
  });
}

Every control this builder creates is non-nullable. The type of form is inferred as FormGroup<{ email: FormControl<string>; password: FormControl<string> }> on its own. The nonNullable property of an existing FormBuilder gives you the same builder. We will get to the inject() we used instead of the constructor in a moment.

FormRecord was also added for dynamic groups whose keys are not known ahead of time. For example, in a form where users can add as many tags as they like, you can use FormRecord<FormControl<string>>.

How Did the Migration Change Your Code?

If you look at your existing forms after the upgrade, you will see something like this:

form = new UntypedFormGroup({
  name: new UntypedFormControl(''),
  age: new UntypedFormControl(0)
});

The migration replaced every existing form class with its Untyped counterpart. UntypedFormGroup, UntypedFormControl, UntypedFormArray and UntypedFormBuilder behave exactly like their Angular 12 versions. So your code keeps compiling after the upgrade and nothing breaks.

Moving to type safety is up to you, and you can do it gradually. Pick a form, remove the Untyped prefixes and fix whatever the compiler points out. Some of those errors will most likely be real bugs that have been sitting there for years.

inject(): Dependency Injection Without the Constructor

In the dependency injection we saw earlier, we always received services through the constructor:

export class ProductListComponent {
  constructor(
    private productService: ProductService,
    private router: Router
  ) { }
}

With Angular 14 you can do the same thing on a field:

import { inject } from '@angular/core';

export class ProductListComponent {
  private readonly productService = inject(ProductService);
  private readonly router = inject(Router);
}

The inject() function is not actually new; it existed before but could only be used in narrow places such as provider factories. Angular 14 made it usable in the constructors and field initializers of components, directives, pipes and services as well.

There is one rule: inject() only works inside an injection context. In practice that means the constructor or a field declaration. Call it inside ngOnInit or a button's click handler and you get an error starting with "inject() must be called from an injection context". You give the waiter your order while you are at the table; you cannot shout it into the kitchen later.

So why bother? The biggest win is inheritance. In Angular 12, every component extending a base class that took services in its constructor had to take those same services in its own constructor and pass them up with super(...). With inject() the base class gets its own dependencies and subclasses no longer carry that burden. The second win is reusable helper functions: you can write small functions that call inject() and use them across different components.

A First Look at Standalone Components

In Angular 12 every component had to be declared in an NgModule. Angular 14 introduced the first exception to that rule:

import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-rating',
  standalone: true,
  imports: [CommonModule],
  template: `
    <span *ngFor="let star of stars" [class.filled]="star <= value">★</span>
  `
})
export class RatingComponent {
  @Input() value = 0;
  stars = [1, 2, 3, 4, 5];
}

A component that says standalone: true is not declared in any module. It lists everything its template needs in its own imports array; here we brought in CommonModule for *ngFor. Think of a tradesman who brings his own toolbox: he does not depend on the site's shared storeroom.

You can use this component in your existing NgModule-based app too. The only difference is that you add it to imports, not declarations:

@NgModule({
  declarations: [AppComponent],
  imports: [BrowserModule, RatingComponent],
  bootstrap: [AppComponent]
})
export class AppModule { }

If you put a standalone component in declarations, the compiler reports an error. To generate a standalone component straight from the CLI, ng g c rating --standalone is enough. Removing AppModule entirely with bootstrapApplication is also possible in this version, but we will cover that part in detail when it becomes stable in Angular 15.

An important caveat: in Angular 14 the standalone APIs were in developer preview. They were ready to try but could change before becoming stable. Waiting for the next version before using them in production was a sensible choice.

Route Titles and Other Notes

In Angular 12, to change each page's title in the browser tab, we called the Title service separately in every component:

export class ProductListComponent implements OnInit {
  constructor(private title: Title) { }

  ngOnInit(): void {
    this.title.setTitle('Ürünler');
  }
}

In Angular 14 the title is part of the route definition:

const routes: Routes = [
  { path: 'products', component: ProductListComponent, title: 'Ürünler' },
  { path: 'cart', component: CartComponent, title: 'Sepetim' }
];

The router updates the title itself once navigation finishes. If you want a custom rule, such as a common suffix on every title, you extend TitleStrategy:

@Injectable({ providedIn: 'root' })
export class AppTitleStrategy extends TitleStrategy {
  private readonly title = inject(Title);

  override updateTitle(snapshot: RouterStateSnapshot): void {
    const pageTitle = this.buildTitle(snapshot);
    this.title.setTitle(pageTitle ? `${pageTitle} | Mağaza` : 'Mağaza');
  }
}

buildTitle finds the active route's title, and we add the suffix and set it. Adding { provide: TitleStrategy, useClass: AppTitleStrategy } to AppModule's providers array is all it takes.

Quick notes

Templates can now access a component's protected members as well; a good option for fields used in the template that do not need to be public. The CLI gained ng completion for shell autocompletion and ng cache for managing the build cache, and a new esbuild-based builder arrived as an experiment. In 14.1 the CanMatch guard arrived as an alternative to CanLoad for lazy loading, and 14.2 brought guards written as plain functions plus the first version of NgOptimizedImage. We will look at those last two in detail in the Angular 15 posts.

A Small Experiment

Take the project you upgraded to Angular 13 and move it to 14. In git diff, see the form classes turn into their Untyped versions. Then pick one form: remove the Untyped prefixes, add nonNullable: true to the string fields and see what the compiler says. Hover over form.value and form.getRawValue() in your editor and compare their types.

Next, replace the constructor injection in that form's component with inject(). Also call inject() inside ngOnInit once on purpose and read the error. Finally, add title to your route definitions and check that the tab title changes.

Standalone components were still an experiment in this version. In the next step, Angular 15 makes the standalone APIs stable: bootstrapApplication, provideRouter, provideHttpClient and the reasoning behind starting an app without an AppModule.