@Directive().inputs And @Input() Are Not Functionally Equivalent In Angular 7.2.13
Historically, when creating my Directives, Components, and Pipes in Angular, I've tried to keep all of my meta-data at the top, inside of the class decorator. I find it very easy to consume when it's all in one place. As opposed to having to scroll up-and-down through a class, searching for disparate meta-data tokens that lend some insight into how the class functions in a Template context. Frankly, I have no idea why the Angular "style guide" recommends that approach - it feels like a poor separation of concerns. That said, until yesterday, I thought it was just a stylistic difference. But, what I realized - after several hours of debugging - is that not all input bindings are created equal. @Directive().inputs and @Input() are not functionally equivalent in Angular 7.2.13; and, they don't play very nicely together.
When it comes to input bindings on an Angular Component or Directive, there are two ways to tell Angular how a class property should map to an element binding in a Template. The first way - the way that I prefer because it clearly collocates most of the class meta-data - is to put the input bindings right in the class decorator. Example:
@Directive({
selector: "[myDirective]",
inputs: [
"myClassProperty: myTemplateAttribute"
]
})
export class MyDirective {
public myClassProperty!: string; // Using definite assignment assertion.
}
Here, I am declaring that the internal class property, "myClassProperty", should map to the template attribute, "myTemplateAttribute".
The second way to do this is to use the @Input() decorator on each individual class property. Example:
@Directive({
selector: "[myDirective]"
})
export class MyDirective {
@Input( "myTemplateAttribute" )
public myClassProperty!: string; // Using definite assignment assertion.
}
Here, I am spreading the meta-data across the entire body of the class, using an individual @Input() binding to tell Angular how to map template attribute, "myTemplateAttribute", onto class property, "myClassProperty".
Now, again, I used to think this was just a stylistic difference. But, it turns out to be more complicated than that: your choice of meta-data affects the way that your Class can be consumed. Specifically, it affects how your can be sub-classed.
To see this behavior, I'm going to create two directives: one super-class and one sub-class. The directives will do nothing but use the ngOnChanges() life-cycle method to log changes to the template attribute bindings. The sub-class will also create an alias the class property defined in the super-class.
First, let's look at the App component that will consume these two directives. This component will never change, so let's just get it out of the way:
@Component({
selector: "my-app",
styleUrls: [ "./app.component.less" ],
template:
`
<p customProp="Testing [customProp]">
Super directive...
</p>
<p customProp2="Testing [customProp2]">
Sub directive...
</p>
`
})
export class AppComponent {
// ....
}
The App component is consuming two Directives, each of which uses an attribute-based selector:
- selector: "[customProp]" - super-class.
- selector: "[customProp2]" - sub-class.
Now, let's look at how we can define our two Directives. Remember, both of these directives are going to have an internal property "customProp"; but, the sub-class is going to create a template-based alias for this property called, "customProp2". However, the meta-data for the input-binding will use two different styles:
@Directive({
selector: "[customProp]"
})
export class CustomPropDirective {
// In the SUPER class, we're going to use the Input meta-data to tell Angular that
// this property maps to a template attribute of the same name.
@Input()
public customProp!: string; // Using definite assignment assertion.
// ---
// PUBLIC METHODS.
// ---
// I get called whenever one of the input bindings is changed.
public ngOnChanges( c: any ) : void {
console.log( "Prop:", this.customProp );
}
}
@Directive({
selector: "[customProp2]",
// In the SUB class, we're going to use the @Directive.inputs meta-data to tell
// Angular that the inherited property maps to a template attribute with a different
// name, "customProp2". So, both classes will use the same internal class property;
// but, they will be using two different template attributes.
inputs: [
"customProp: customProp2"
]
})
export class CustomProp2Directive extends CustomPropDirective {
// ....
}
As you can see, both the super-class and the sub-class have the same internal property, "customProp". But, they each map to a different template-based attribute binding: "customProp" and "customProp2" respectively.
Now, if we run the App component from above, we should get two console.log() for the inherited ngOnChanges() life-cycle method in each component. But, when we run this Angular app, we get the following output:
As you can see, the ngOnChanges() life-cycle method only gets fired for our super-class Directive. The sub-class directive does get instantiated; but, its attribute binding for "customProp2" never gets wired-up. As such, Angular doesn't believe that it has any input bindings to watch.
Why does this happen? I have no idea. My best guess is that the sub-class is somehow inheriting the @Input() meta-data from the super-class, which then overriding the @Directive() meta-data in the sub-class.
To prove that this is a meta-data issue, let's try this again using the @Directive() meta-data in both classes:
@Directive({
selector: "[customProp]",
// In the SUPER class, we're going to use the @Directive.inputs meta-data to tell
// Angular that the class property, "customProp", maps to a template attribute of the
// same name.
inputs: [
"customProp"
]
})
export class CustomPropDirective {
public customProp!: string; // Using definite assignment assertion.
// ---
// PUBLIC METHODS.
// ---
// I get called whenever one of the input bindings is changed.
public ngOnChanges( c: any ) : void {
console.log( "Prop:", this.customProp );
}
}
@Directive({
selector: "[customProp2]",
// In the SUB class, we're going to use the @Directive.inputs meta-data to tell
// Angular that the inherited property maps to a template attribute with a different
// name, "customProp2". So, both classes will use the same internal class property;
// but, they will be using two different template attributes.
inputs: [
"customProp: customProp2"
]
})
export class CustomProp2Directive extends CustomPropDirective {
// ....
}
As you can see, in this version, all we've done is change the way we are declaring the input binding meta-data in the super-class. Instead of using the disparate @Input() decorators, we're collocating all of our class meta-data in one convenient place.
Now, if we run this Angular application again, we get the following output:
As you can see, this time, the example works as you would expect. Angular sees that both Directives are declaring input bindings that map individual template attributes - "customProp" and "customProp2", respectively - to the same internal class property.
Seeing this, I now believe that my choice in meta-data syntax - collocating as much of the meta-data at the top of a Class - is not only easier to consume within a single component, it is also easier to consume across components in a hierarchical relationship.
Now, this isn't to say that the two meta-data options can't play together. I'm only saying that collocating your meta-data makes life more flexible. For example, You can certainly use the @Input meta-data to override @Directive().inputs:
@Directive({
selector: "[customProp]",
// In the SUPER class, we're going to use the @Directive.inputs meta-data to tell
// Angular that the class property, "customProp", maps to a template attribute of the
// same name.
inputs: [
"customProp"
]
})
export class CustomPropDirective {
public customProp!: string; // Using definite assignment assertion.
// ---
// PUBLIC METHODS.
// ---
// I get called whenever one of the input bindings is changed.
public ngOnChanges( c: any ) : void {
console.log( "Prop:", this.customProp );
}
}
@Directive({
selector: "[customProp2]"
})
export class CustomProp2Directive extends CustomPropDirective {
// Using @Input() meta-data in the sub-class WILL override the @Directive.inputs in
// the super-class.
@Input( "customProp2" )
public customProp!: string; // Using definite assignment assertion.
}
If we run this, we get the expected output - the ngOnChanges() life-cycle method firing for both Directives. This is because the @Input() binding in the sub-class successfully overrides the @Directive.inputs in the super-class. But, at the cost of having to completely redefine the inherited class property, losing some of the efficiency of the inheritance.
Will you ever run into this situation? Probably not. I've been writing Angular 2+ for like 4-years now and I only just ran into this problem for the first time. But, if nothing else, this problem reaffirms my belief that collocating all (or as much as possible of) the meta-data at the top of class makes the meta-data both easier to find and more flexible. In this particular context, using the class-based meta-data (instead of property-based meta-data) doesn't couple your sub-classes into any particular style.
NOTE: While I didn't test @Directive().outputs vs. @Output(), I can only assume that the meta-data choices will exhibit the same behavior (as the Input equivalents) as they also pertain to mapping template attribute bindings to class properties.
Want to use code from this post? Check out the license.
Reader Comments
What happens if you use:
In both super/sub classes?
@Charles,
That works as well. My assumption here is that the
@Input()
applies meta-data to the actual property, not to the parent class. So, if you use@Input()
on both the super property and the sub property, the sub property version will override as expected.OK. Cool. Good to know about these advanced intricacies...