Tabris Decorators Part 1: Intro to TypeScript Decorators
NOTE: This article is about Tabris 2.x. Some information is outdated when targeting Tabris 3.x.
This article will give you some practical know-how on TypeScript decorators, what they are, how to write your own, and how they can improve your code. It is part of a series of blog posts dedicated to a Tabris.js extension called tabris-decorators, which makes developing mobile apps with Tabris.js 2.x and TypeScript more convenient. The extension features data binding capabilities, enhanced event handling and dependency injection explicitly designed for the Tabris mobile framework. To use it the npm module must be installed separately.
TypeScript decorators are functions that can be used to manipulate a class definition at runtime, e.g. replacing parts of it or adding metadata. They can be called with the @
sign when placed before a member, a parameter, or the class itself:
1 2 3 4 5 6 7 |
@classDeco class Foo { constructor(@paramDeco public param: string) {} @memberDeco public prop: string; } |
Your own custom value-checking property decorator
Decorators are a great way to move very technical, repetitive code out of sight so you can concentrate on what the class is actually about. They are not TypeScript exclusive, but they are more powerful in TypeScript. To explain why let’s write a property decorator ourselves.
First, you must enable two compiler options in your tsconfig.json
:
1 2 3 4 5 6 |
{ "compilerOptions": { "experimentalDecorators": true, "emitDecoratorMetadata": true } } |
Okay, now assume you have a simple model class like this:
1 2 3 4 5 6 |
class Person { public name: string; public age: number; public email: string; public married: boolean; } |
The benefit of writing and using such a class in TypeScript instead of JavaScript is obviously that you can (usually) rely on each of these properties to only hold a value of the appropriate type. That doesn’t mean that these are always useful values:
1 2 |
let person = new Person(); person.age = -23; // oh-oh! |
The usual way to prevent this would be to write property setter (and getter) to check that the number is positive. This turns one line into up to ten lines, depending on how you go about it.
Maybe it’s fine to fix this one instance of this issue, but to be consistent, you have that for every property in your project that requires some kind of validation that goes beyond what TypeScript can manage at compile time. Here is where a decorator can help:
1 2 3 4 5 6 7 |
class Person { @positive public age: number; } let person = new Person(); person.age = 23; // OK person.age = -23; // Error: Positive number expected |
Maybe this looks a bit like magic, but it isn’t. Actually, positive
is just a normal function:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
function positive(prototype: object, property: string) { const sym = Symbol(); Object.defineProperty(prototype, property, { enumerable: true, set(value: number) { if (value < 0) { throw new Error('Positive number expected'); } this[sym] = value; }, get() { return this[sym]; } }); } |
Okay, so what does this do? First, the parameters: prototype
is the object that the class instance methods, including setters and getters, are stored in. It’s NOT the instance itself, the instance inherits from this object. property
is the name of the property the decorators was applied to. The call to Object.defineProperty
creates a setter and getter on the prototype, so that any access to person.age
will go through them.
We set
enumrable
to true to keep the previous behavior whereage
is picked up byfor ... in
loops andJSON.stringify
.
Let’s take a closer look at the setter:
1 2 3 4 5 6 |
set(value: number) { if (value < 0) { throw new Error('Positive number expected'); } this[sym] = value; } |
The check should speak for itself. If the value is smaller than zero, throw an error, the property will keep its previous value.
While this only checks the value to be positive, in practice I would strongly recommend additional criteria like excluding
NaN
andinfinity
. And of course, you could modify the behavior to only printing a warning and not setting the value, or adjust the value to be at least zero, and so on.
The tricky part is where to actually store the value. You can’t use a closure or the prototype itself, that would make all instances of Person
have the same age
value. However, we do have access to the instance object, since that is the context (value of this
) the setter is executed in. (That’s why it’s important NOT to use arrow functions to define the setter.)
You could just follow the usual pattern of storing the value in a pseudo-private instance property that has the same name as the property with a prefix, e.g., _age
, BUT: This could lead to a number of unexpected behaviors since nowhere in the actual class definition do you see such a member declared.
The solution is another relatively new JavaScript type called symbols. Every call of Symbol()
creates a completely unique value that may be used as an object property identifier that is not enumerable. In other words: Only those with access to this exact symbol can also access any properties set via the symbol. This finally solves the old JavaScript issue of having (runtime) private properties. So by creating a symbol via…
1 |
const sym = Symbol(); |
…and then using it in the setter…
1 |
this[sym] = value; |
…and the getter…
1 |
return this[sym]; |
we have a way of attaching data to the instance of Person
that no other code has access to.
Who do you trust? – A generic type check decorator
Even if the TypeScript type system is sufficient to express what values are valid for your property, there is no guarantee that it is respected at runtime. The obvious example would be any kind of interaction with code that was written in JavaScript, which has no compile-time checks whatsoever. But let’s stay in TypeScript-land. Wouldn’t it be cool to do something like this:
1 2 |
const jack = new Person(); Object.assign(jack, JSON.parse(await fetchData('jack'))); |
In this scenario, fetchData
may read the values for 'jack'
from a database somewhere, a user-provided file, or just something your own application put into localStorage
. If you are confident that these entities are reliably providing valid data, you can just leave it at that. But the point of TypeScript is that you can be sure a boolean
property always actually to be a boolean
, and not maybe a string that looks like a boolean, e.g., 'false'
.
Of course, you could write decorators as above for every property type you use – this is what you would have to do in pure JavaScript. But aside from the considerable amount of additional code – you would have to pay extra attention to always use the right decorator for each property, and not accidentally add something like @checkIsString
to a boolean
property. (Of course the same is true for the above @positive
example, more on that later.)
Instead, imagine something like this:
1 2 3 4 |
class Person { @check public name: string; @check public married: boolean; } |
One decorator, valid for any data type your JSON may contain. How can this be done? Like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
import 'reflect-metadata'; function check(prototype: object, property: string) { const sym = Symbol(); const constr = Reflect.getMetadata('design:type', prototype, property); Object.defineProperty(prototype, property, { enumerable: true, set(value: any) { if (!(value instanceof constr) && (typeof value !== constr.name.toLocaleLowerCase())) { throw new Error(`Failed to set "${property}": ${constr.name} expected, got ${typeof value}`); } this[sym] = value; }, get() { return this[sym]; } }); } |
It follows mostly the same pattern as the first example, but the type that is expected is dynamic. This is where the emitDecoratorMetadata
compiler option comes in: It makes tsc
add type metadata to all decorated properties and parameters, which can be read via the extended Reflect
API. This requires an additional module that we install on your command line with npm i reflect-metadata
and load in code via import 'reflect-metadata'
.
You can now get the property type like this:
1 |
const constr: Function = Reflect.getMetadata('design:type', prototype, property); |
Now constr
is the constructor function that would create a valid value, or in our case of primitives, their respective boxed type. For example, a number property is represented by the Number
constructor. Usually you only use these kinds of constructors for their static methods, but in this case, we actually exploit the fact that their names match that of the results of a typeof
check. Therefore, this snippet provides a reasonably secure type check:
1 2 3 |
if (!(value instanceof constr) && (typeof value !== constr.name.toLocaleLowerCase())) { throw new Error(`Failed to set "${property}": ${constr.name} expected, got ${typeof value}`); } |
The result:
1 2 3 4 |
const jack = new Person(); Object.assign(jack, JSON.parse( '{"name": "Jack Bauer", "married": "yes"}' )); // Error: Failed to set "married": Boolean expected, got string |
Unfortunately, this generic approach does not work with advanced types such as unions. In these cases, the value of
type
is justObject
. To prevent false positives you should throw an Error when this happens. To work around this you need to either write dedicated decorators or avoid advanced types. Also, for type-specific decorators such as@positive
you may want to throw iftype
does not match what the decorator was designed to check for.
If you wanted to, you could also use the property metadata to check that all values have been set after initializing the instance. Another use case would be to check method/constructor parameters. That would be a bit more involved, but very useful if you are writing a TypeScript library that will be consumed in a JavaScript project. In that case, any public API couldn’t trust a method caller to respect the parameter types, so runtime checks are a must.
Decorators Factories
You can create even more powerful decorators by providing a factory that is then called inline. For example, instead of creating a @email
decorator, write a factory that takes a regular expression:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
function pattern(regEx: RegExp) { return (prototype: object, property: string) => { const sym = Symbol(); Object.defineProperty(prototype, property, { enumerable: true, set(value: string) { if (!regEx.test(value)) { throw new Error(`Invalid ${property} "${value}"`); } this[sym] = value; }, get() { return this[sym]; } }); }; } |
And now you can do this…
1 2 3 4 5 6 7 8 |
class Person { @pattern(/^.+@.+\..+$/) public email: string; } let jack = new Person(); jack.email = 'jack@mail.domain'; // OK jack.email = 'none'; // Error: Invalid email "none" |
… but of course you can also use the decorator for other properties that expect other kinds of strings.
Feedback is welcome!
Want to join the discussion?Feel free to contribute!