Using The "Definite Assignment Assertion" To Define Required Input Bindings In Angular 7.1.1
In an Angular application, Directives represent a special type of Class. In a sense, they are a standard JavaScript Class with properties and methods. But, unlike a Class that gets created and consumed explicitly in your own logic, Directive classes are created, consumed, and managed implicitly by the Angular renderer. As such, properties that might otherwise be required as a constructor argument are, instead, provided through template input bindings. Which begs the question: how do you define a "required" property / input binding that is not actually available at the time of instantiation? As explained in the strictPropertyInitialization flag clean-up issue on GitHub, people on the Angular team have started to use TypeScript's "Definite Assignment Assertion" to denote required Class properties as "defined" even when they won't truly be defined until after the Directive's class constructor has been executed. I had never seen the "Definite Assignment Assertion" before, so I wanted to try it out for myself in a Component Directive in Angular 7.1.1.
Run this demo in my JavaScript Demos project on GitHub.
View this code in my JavaScript Demos project on GitHub.
To explore this concept, I created a "Badge" component that accepts a [user] input binding and renders the badge template with name, email, and avatar. The whole reason-to-be for this component is to render the passed-in User object. As such, this component makes no sense without the [user] input. This input binding is therefore required in order for this component to work. Without the user property, this Badge component would be in an "invalid state".
Before learning about the "Definite Assignment Assertion" notation in TypeScript, I might have tried to approach this Component definition by making the "user" property optional (meaning, for example, that it can also be set to null):
// Import the core angular services.
import { ChangeDetectionStrategy } from "@angular/core";
import { Component } from "@angular/core";
import { OnChanges } from "@angular/core";
import { OnInit } from "@angular/core";
import { SimpleChanges } from "@angular/core";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
interface User {
name: string;
email: string;
avatarUrl: string;
}
@Component({
selector: "bn-badge",
inputs: [ "user" ],
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrls: [ "./badge.component.less" ],
template:
`
<div class="layout">
<div class="layout__avatar">
<img [src]="user.avatarUrl" class="avatar" />
</div>
<div class="layout__info info">
<div class="info__name">
{{ user.name }}
</div>
<div class="info__email">
{{ user.email }}
</div>
</div>
</div>
`
})
export class BadgeComponent implements OnInit, OnChanges {
// Since the user is provided by an outside context (as a required input binding),
// it's defined value will not be know at instantiation time. As such, we'll need
// to allow it to be null (and then enforce its value later on).
public user: User | null;
// I initialize the badge component.
constructor() {
this.user = null;
}
// ---
// PUBLIC METHODS.
// ---
// I get called after input bindings have been changed.
// --
// CAUTION: If the calling context provides no input bindings on the bn-badge tag,
// this method will never get called.
public ngOnChanges( changes: SimpleChanges ) : void {
this.assertUser();
}
// I get called after the input bindings have been defined for the first time.
// --
// NOTE: This method still gets called even if there are no input bindings. This is
// different from the ngOnChanges() method above, which will only get called if input
// bindings are actually defined.
public ngOnInit() : void {
this.assertUser();
}
// ---
// PRIVATE METHODS.
// ---
// I assert that the user is defined (ie, that the required input binding was
// actually provided by the calling context).
private assertUser() : void {
if ( ! this.user ) {
throw( new Error( "Required input [user] not provided." ) );
}
}
}
In this case, in the BadgeComponent constructor, I have to set the user property to null otherwise TypeScript complains that I didn't fully initialize the class property (thanks to the "strictPropertyInitialization" tsconfig setting). Of course, in the ngOnInit() and ngOnChanges() event-handlers, I am attempting to assert that the value must be defined (as a required input binding). But, when I go to compile this code, I get the following TypeScript error:
ERROR in app/badge.component.ts.BadgeComponent.html(4,10): : Object is possibly 'null'.
As you can see, TypeScript and Angular are complaining that I am attempting to use a potentially null value in my template expressions. Now, all of the logic for this component happens to be in the template; but, if my Class made additional references to the "user" property within its methods, similar errors would be thrown by the TypeScript compiler.
To fix this workflow, Angular internals have started to use the "Definite Assignment Assertion". This is a property name followed by an exclamation point. For example:
public user!: User;
This assertion tells TypeScript that the "user" property (aka, our required input binding) may look like it's not being fully initialized; but, that TypeScript should trust that the application is going to define this value before it is consumed by the given Class. In other words, we are telling TypeScript to treat this as a required property, but to not validate its initialization.
Of course, TypeScript can only make assertions about compile-time truths - it doesn't know anything about the runtime. As such, this won't prevent runtime errors if the "user" property is undefined; but, this assertion will better express the intended use of the Component Class.
Let's take a look at what the BadgeComponent looks like when we add the "Definite Assignment Assertion" notation:
export class BadgeComponent implements OnInit, OnChanges {
// Since the user is provided by an outside context (as a required input binding),
// it's defined value will not be know at instantiation time. As such, we'll use the
// "Definite Assignment Assertion" (!) to tell TypeScript that we know this value
// will be defined in some way by the time we use it; and, that TypeScript should
// not worry about the value until then.
public user!: User;
// I initialize the badge component.
constructor() {
// We've used the "Definite Assignment Assertion" to tell TypeScript that this
// value will be provided by a non-obvious means (provided after instantiation).
// As such, we don't need to initialize it.
// --
// this.user = null;
}
// ---
// PUBLIC METHODS.
// ---
// I get called after input bindings have been changed.
// --
// CAUTION: If the calling context provides no input bindings on the bn-badge tag,
// this method will never get called.
public ngOnChanges( changes: SimpleChanges ) : void {
this.assertUser();
}
// I get called after the input bindings have been defined for the first time.
// --
// NOTE: This method still gets called even if there are no input bindings. This is
// different from the ngOnChanges() method above, which will only get called if input
// bindings are actually defined.
public ngOnInit() : void {
this.assertUser();
}
// ---
// PRIVATE METHODS.
// ---
// I assert that the user is defined (ie, that the required input binding was
// actually provided by the calling context).
private assertUser() : void {
if ( ! this.user ) {
throw( new Error( "Required input [user] not provided." ) );
}
}
}
As you can see, we are denoting the "user" property as "defined". Which allows the code to compile even though we've commented-out the constructor initialization. In this case, I'm still using the ngOnInit() and ngOnChanges() life-cycle methods to validate the input binding. But these are here to better report runtime errors and express the intent of the class. After all, if the input binding is missing, something is going to break.
To see this "Definite Assignment Assertion" in action, I've created an App component that attempts to consume the BadgeComponent both with and without a provided [user] input binding:
// Import the core angular services.
import { Component } from "@angular/core";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
interface User {
id: number;
name: string;
email: string;
avatarUrl: string;
startedAt: number;
}
@Component({
selector: "my-app",
styleUrls: [ "./app.component.less" ],
template:
`
<div *ngFor="let user of users">
<bn-badge [user]="user"></bn-badge>
</div>
<!--
We know this one will break because there is no [user] binding. This is just
here to demonstrate what it looks like when it breaks.
-->
<div>
<bn-badge></bn-badge>
</div>
`
})
export class AppComponent {
public users: User[];
// I initialize the app component.
constructor() {
this.users = [
{
id: 1,
name: "Kim Doro",
email: "ben+kim@bennadel.com",
avatarUrl: "http://www.gravatar.com/avatar/5cbcec91c352ed84fa4ad6fc42fd2a05.jpg?s=150",
startedAt: Date.now()
},
{
id: 2,
name: "Sarah O'Neill",
email: "ben+sarah@bennadel.com",
avatarUrl: "http://www.gravatar.com/avatar/a65ac17d587bc4b2a0d4075fc8cb2938.jpg?s=150",
startedAt: Date.now()
},
{
id: 3,
name: "Tricia Nakatomi",
email: "ben+tricia@bennadel.com",
avatarUrl: "http://www.gravatar.com/avatar/e75d5660d83e33924a51b22cc1db0a91.jpg?s=150",
startedAt: Date.now()
}
];
}
}
As you can see, the last of the "bn-badge" elements is omitting the [user] input binding. And, when we run this code in the browser, we get the following output:
Clearly, if the [user] input binding is required but omitted from the calling code, the page will still break. After all, the BadgeComponent is in an invalid state. But, the BadgeComponent itself expresses clear intent as to how the input binging is supposed to be used.
When you author an Angular Component, some input bindings are optional and some are required. With optional input bindings, you can provide default or fallback values in order to make the class properties easier to work with. But, when it comes to required input bindings, there is no default or fallback. As such, TypeScript will complain that the required properties aren't properly initialized. Thankfully, the "Definite Assignment Assertion" allows us to tell TypeScript to relax the property validation and trust that the Angular application will define these values before they are used.
Want to use code from this post? Check out the license.
Reader Comments
Hello,
Nice Article.
I am actually trying to utilize the definite assertion in my angular project.
I have a model class of my object, which is your interface by the way.
But Im trying to model the db.
So i have a class Rating{rating:number;}
And an export class Review which has rating!:Rating;
In my ts class, i did review.rating.rating = 0,
but it is seeing review.rating as undefined.
Please can you help me
@Rachael,
So, the Definite Assignment Assertion won't actually influence the runtime value of the property - it will only prevent the TypeScript compiler from complaining that the property isn't being initialized. The point of the Definite Assignment Assertion is handle cases in which a property is being set outside of the current context (such as with an input-binding), and we are assuring the compiler that this value will be set by the time it is consumed.
Based on what you are saying, it sounds like you are attempting to reference
reviewer.rating
before you actually set it.