I recently played with Angular and found an uncomfortable problem: Declaring an input property with a TypeScript type does not guarantee that the input value will always be of this type. This is because the Angular framework may update an input with a wrongly typed value and thus is able to “circumvent” the TypeScript type system. As a result, you may end up with strange and unexpected runtime errors.
Let’s have a look at this simple Angular component:
@Component({ selector: "my-component" })
export class MyComponent {
@Input()
enabled = true;
showValue() {
alert(typeof this.enabled);
}
}
As shown in the HTML below, if the enabled
input is set as an HTML attribute, the type of the value will be a string, even if the property is defined as boolean
. When the showValue
method is called, the text in the alert box will be string
.
<my-component enabled="false"></my-component>
To solve this problem I wrote a TypeScript property decorator which intercepts the property setter and converts the value if it has the wrong type.
First, I declared the most common converters:
export var StringConverter = (value: any) => {
if (value === null || value === undefined || typeof value === "string")
return value;
return value.toString();
}
export var BooleanConverter = (value: any) => {
if (value === null || value === undefined || typeof value === "boolean")
return value;
return value.toString() === "true";
}
export var NumberConverter = (value: any) => {
if (value === null || value === undefined || typeof value === "number")
return value;
return parseFloat(value.toString());
}
The implemented property decorator InputConverter
is essentially a factory method which creates a decorator function. This function is called for each property which has a decorator annotation (i.e. @InputConverter(BooleanConverter)
).
export function InputConverter(converter?: (value: any) => any) {
return (target: Object, key: string) => {
if (converter === undefined) {
var metadata = (<any>Reflect).getMetadata("design:type", target, key);
if (metadata === undefined || metadata === null)
throw new Error("The reflection metadata could not be found.");
if (metadata.name === "String")
converter = StringConverter;
else if (metadata.name === "Boolean")
converter = BooleanConverter;
else if (metadata.name === "Number")
converter = NumberConverter;
else
throw new Error("There is no converter for the given property type '" + metadata.name + "'.");
}
var definition = Object.getOwnPropertyDescriptor(target, key);
if (definition) {
Object.defineProperty(target, key, {
get: definition.get,
set: newValue => {
definition.set(converter(newValue));
},
enumerable: true,
configurable: true
});
} else {
Object.defineProperty(target, key, {
get: function () {
return this["__" + key];
},
set: function (newValue) {
this["__" + key] = converter(newValue);
},
enumerable: true,
configurable: true
});
}
};
}
If the converter is undefined
, then the function tries to use TypeScript reflection to find the correct converter for the properties type. In order to use this feature, you need to enable the option emitDecoratorMetadata
in the TypeScript compiler (or add the TypeScriptEmitDecoratorMetadata
tag to your .csproj
) and add a polyfill for the reflection API (for example using es6-shim).
The method also checks if a property descriptor is already available and calls its setter and getter to read and write values. If the property is not yet initialized, then the values are read and written from an instance field.
Now, the property decorator can be used very easily: Import the decorator function and create a property annotation with the desired converter type:
export class MyComponent {
@Input()
@InputConverter(BooleanConverter)
enabled = true;
}
The converter is automatically set, if reflection is enabled. In the following code, the enabled
property is converted using the BooleanConverter
:
export class MyComponent {
@Input()
@InputConverter()
enabled: boolean = true;
}
I personally think this feature should be part of Angular’s Input decorator because every TypeScript developer expects that the type of input values are always correct…
What do you think? Should this feature be baked into the Angular 2 core framework?
Rico Suter
SOFTWARE ENGINEERING
EDIT
Angular 2 Decorator JavaScript Reflection TypeScript