TypeScript and Ember.js Update, Part 3
Computed properties, actions, mixins, and class methods.
You write Ember.js apps. You think TypeScript would be helpful in building a more robust app as it increases in size or has more people working on it. But you have questions about how to make it work.
This is the series for you! Iâll talk through everything: from the very basics of how to set up your Ember.js app to use TypeScript to how you can get the most out of TypeScript todayâand Iâll be pretty clear about the current tradeoffs and limitations, too.
(See the rest of the series. â)
Note: if youâre following along with this as I publish it in late January 2018, please go back and read the end of Part 2, which I updated substantially yesterday evening to include more material I missed in the first version of that post, but which belonged there and not here.
In the previous posts in this series, I introduced the big picture of how the story around TypeScript and Ember.js has improved over the last several months and walked through some important background on class properties. In this post, Iâll build on that foundation to look closely at computed properties, actions, and mixins.
Hereâs the outline of this update sequence:
- Overview, normal Ember objects, component arguments, and injections.
- Class propertiesâsome notes on how things differ from the
Ember.Object
world. - Computed properties, actions, mixins, and class methods (this post).
- Using Ember Data, and service and controller injections improvements.
- Mixins and proxies; or: the really hard-to-type-check bits.
A detailed example (contâd.) â computed properties, mixins, actions, and class methods
Letâs start by recalling the example Component weâre working through:
import Component from '@ember/component';
import { computed, get } from '@ember/object';
import Computed from '@ember/object/computed';
import { inject as service } from '@ember/service';
import { assert } from '@ember/debug';
import { isNone } from '@ember/utils';
import Session from 'my-app/services/session';
import Person from 'my-app/models/person';
export default class AnExample extends Component {
// -- Component arguments -- //
model: Person; // required
modifier?: string; // optional, thus the `?`
// -- Injections -- //
session: Computed<Session> = service();
// -- Class properties -- //
aString = 'this is fine';
aCollection: string[] = [];
// -- Computed properties -- //
// TS correctly infers computed property types when the callback has a
// return type annotation.
fromModel = computed(
'model.firstName',
function(this: AnExample): string {
return `My name is ${get(this.model, 'firstName')};`;
}
);
aComputed = computed('aString', function(this: AnExample): number {
return this.lookAString.length;
});
isLoggedIn = bool('session.user');
savedUser: Computed<Person> = alias('session.user');
actions = {
addToCollection(this: AnExample, value: string) {
const current = this.get('aCollection');
this.set('aCollection', current.concat(value));
}
};
constructor() {
super();
assert('`model` is required', !isNone(this.model));
this.includeAhoy();
}
includeAhoy(this: AnExample) {
if (!this.get('aCollection').includes('ahoy')) {
this.set('aCollection', current.concat('ahoy'));
}
}
}
Computed properties
We already covered component arguments and injections as well as basic class properties and the exceptions to normal class-property ways of doing things, in Parts 1 and 2. With that background out of the way, we can now turn to computed properties. Iâm including the component arguments in this code sample because theyâre referenced in the computed property. Assume Person
is a pretty âpersonâ representation, with a firstName
and a lastName
and maybe a few other properties.
// -- Component arguments -- //
model: Person; // required
modifier?: string; // optional, thus the `?`
// -- Computed properties -- //
// TS correctly infers computed property types when the callback has a
// return type annotation.
fromModel = computed(
'model.firstName',
function(this: AnExample): string {
return `My name is ${get(this.model, 'firstName')};`;
}
);
aComputed = computed('aString', function(this: AnExample): number {
return this.lookAString.length;
});
isLoggedIn = bool('session.user');
savedUser: Computed<Person> = alias('session.user');
computed
properties
When using a computed property in the brave new world of ES6 classes, we normally just assign them as instance properties. As mentioned in the previous post, and in line with my comments above, this has some important tradeoffs around performance. If you need the absolute best performance, you can continue to install them on the prototype by doing this instead:
export default class MyComponent extends Component.extend({
fromModel: computed(
'model.firstName',
function(this: AnExample): string {
return `My name is ${get(this.model, 'firstName')};`;
}
),
}) {
// other properties
}
Whichever way you do it, TypeScript will correctly infer the type of the computed property in question (here fromModel
) as long as you explicitly annotate the return type of the callback passed to computed
. Accordingly, in this case, the type of fromModel
is ComputedProperty<string>
. The fact that itâs a ComputedProperty
means if you try to treat it as a plain string, without using Ember.get
to unwrap it, TypeScript will complain at you.1
// type checking error:
this.fromModel.length;
// type checking valid:
this.get('fromModel').length;
The other really important thing to note here is the use of this: MyComputed
. By doing this, weâre telling TypeScript explicitly that the type of this
in this particular function is the class context. We have to do this here, because we donât have any way to tell the computed
helper itself that the function inside it will be bound to the this
context of the containing class. Put another way: we donât have any other way to tell TypeScript that one of the things computed
does is bind this
appropriately to the function passed into it; but gladly we do have this wayâotherwise weâd be out of luck entirely! (Youâll see the same thing below when we look at actions). The boilerplate is a bit annoying, admittedlyâbut it at least makes it type-check.
Computed property macros
Beyond computed
, there are a lot of other computed property tools we use all the time. Some of them can (and therefore do) infer the type of the resulting computed property correctly. But there are a bunch of idiomatic things that TypeScript does not and cannot validate â a number of the computed property macros are in this bucket, because they tend to be used for nested keys, and as noted above, TypeScript does not and cannot validate nested keys like that.
We have a representative of each of these scenarios:
isLoggedIn = bool('session.user');
savedUser: Computed<Person> = alias('session.user');
In the case of isLoggedIn
, the bool
helper only ever returns a boolean, so the type of isLoggedIn
is ComputedProperty<boolean>
. In the case of savedUser
, since TypeScript canât figure out what the nested key means, we have to specify it explicitly, using Computed<Person>
.2 In these cases, you have to do the work yourself to check that the type you specify is the correct type. If you write down the wrong type here, TypeScript will believe you (it doesnât have any other good option!) and youâll be back to things blowing up unexpectedly at runtime.
The typings supply the concrete (non-any
) return type for: and
, bool
, equal
, empty
, gt
, gte
, lt
, lte
, match
, map
, max
, min
, notEmpty
, none
, not
, or
, and sum
.
On nested keys
As noted above, TypeScript cannot do a lookup for any place using nested keysâwhich means that this.get('some.nested.key')
wonât type-check, sadly. This is an inherent limitation of the type system as it stands today, and for any future I can foresee. The problem is this: what exactly is 'some.nested.key'
? It could be what we use it for in the usual scenario in Ember, of course: a string representing a lookup on a property of a property of a property of whatever this
is. But it could equally well be a key named 'some.nested.key'
. This is perfectly valid JavaScript, after all:
const foo = {
['some.nested.key']: 'Well, this is weird, but it works',
};
TypeScript does not today and presumably never will be able to do that lookup. The workaround is to do one of two things:
If you know you have a valid parent, you can do the (catastrophically ugly, but functional) nested
Ember.get
that now litters our codebase:import { get } from '@ember/object'; const value = get(get(get(anObject, 'some'), 'nested'), 'key');
Yes, itâs a nightmare. But⦠it type-checks, and it works well enough in the interim until we get a decorators-based solution that lets us leverage RFC #281.
Use the
// @ts-ignore
to simply ignore the type-unsafety of the lookup. This approach is preferable when you donât know if any of the keys might be missing. If, for example, eithersome
ornested
wereundefined
ornull
, the lookup example above in (1) would fail.import { get } from '@ember/object'; // @ts-ignore -- deep lookup with possibly missing parents const value = get(anObject, 'some.nested.key');
Actions
What about actions? As usual, these just become class instance properties in the current scheme.
actions = {
addToCollection(this: AnExample, value: string) {
const current = this.get('aCollection');
this.set('aCollection', current.concat(value));
}
};
As with computed properties, we need the this
type declaration to tell TypeScript that this method is going to be automatically bound to the class instance. Otherwise, TypeScript thinks the this
here is the actions
hash, rather than the MyComponent
class.3
Happily, thatâs really all there is to it for actions: theyâre quite straightforward other than needing the this
type specification.
Types in .extend({...})
blocks
By and large, you can get away with using the same this: MyComponent
trick when hacking around prototypal extension problems, or performance problems, by putting computed properties in a .extend({...}
block. However, you will sometimes see a type error indicating that the class is referenced in its own definition expression. In that case, you may need to judiciously apply any
, if you canât make it work by using normal class properties.
constructor
and class methods
ES6 class constructors and class methods both work as youâd expect, though as weâll see youâll need an extra bit of boilerplate for methods, at least for now.
constructor() {
super();
assert('`model` is required', !isNone(this.model));
this.includeAhoy();
}
includeAhoy(this: AnExample): void {
if (!this.get('aCollection').includes('ahoy')) {
this.set('aCollection', current.concat('ahoy'));
}
}
For the most part, you can just switch to using normal ES6 class constructors instead of the Ember init
method. You can, if you so desire, also move existing init
functions passed to a .extends({ ...})
hash to class methods, and theyâll work once you change this._super(...arguments)
to super.init(...arguments)
. Itâs worth pausing to understand the relationship between init
and prototypal init
and the constructor
. An init
in the .extends()
hash runs first, then an init
method on the class, then the normal constructor
.4
Note that you do not need to (and cannot) annotate the constructor
with this: MyComponent
. Depending on the class youâre building, you may occasionally have type-checking problems that come up as a result of this. Iâve only ever seen that happen when using computed properties while defining a proxy,5 but it does come up. In that case, you can fall back to using init
as a method, and set this: MyComponent
on it, and things will generally fall out as working correctly at that point. When it comes up, this seems to be just a limitation of what this
is understood to be in a constructor
given Emberâs rather more-complex-than-normal-classes view of what a given item being constructed is.
Other class methods do also need the this
type specified if they touch computed properties. (Normal property access is fine without it.) Thatâs because the lookups for ComputedProperty
instances (using Ember.get
or Ember.set
) need to know what this
is where they should do the lookup, and the full this
context isnât inferred correctly at present. You can either write that on every invocation of get
and set
, like (this as MyComponent).get(...)
, or you can do it once at the start of the method. Again, a bit boiler-platey, but it gets the job done and once youâre used to it itâs minimal hassle.6
One last note, which I didnât include in the example: if you have a function (usually an action) passed into the component, you can define it most simply by just using onSomeAction: Function;
in the class definition, right with other class arguments. However, itâs usually most helpful to define what the type should actually be, for your own sanity check if nothing else. As with e.g. model
in this example, we donât actually have a good way to type-check that what is passed is correct. We can, however, at least verify in the constructor that the caller passed in a function using assert
, just as with other arguments.
Summary
So thatâs a wrap on components (and controllers, which behave much the same way).
In the next post, Iâll look at the elephant in the room: Ember Data (and closely related concern Ember CLI Mirage). While you can make Ember Data stuff largely work today, itâs still a ways from Just Worksâ¢ï¸, sadly, but weâll cover how to work around the missing piecesâweâve gotten there in our own codebase, so you can, too!
As mentioned in Part 2, this problem doesnât go away until we get decorators, unless youâre putting them on the prototype via
.extends()
âbut see below for the problems with that. The short version is, we need decorators for this to actually be nice. Once we get decorators, we will be able to combine them with the work done for RFC #281 and normal lookup will just work:
â©@computed('model.firstName') get fromModel() { return `My name is ${this.model.firstName};`; }
Iâve used
Computed<Person>
and similar throughout here because itâs the most clear while still being reasonably concise. The actual type name in Emberâs own code isComputedProperty
, butComputedProperty<Person>
is long, and it wouldnât have added any real clarity here. In my own codebase, we useCP
(for âComputed Propertyâ) for the sake of brevityâso here that would just beCP<Person>
.â©In the future, this problem will hopefully be solved neatly by decorators:
@action addToCollection(value: string) { const current = this.get('aCollection'); this.set('aCollection', current.concat(value)); }
For today, however, specifying a
this
type is where itâs at.â©You can see this for yourself in this Ember Twiddleâjust open your developer tools and note the sequence.â©
Proxies, along with details of mixins, are a subject Iâm leaving aside for Part 5, otherwise known as the âwow, this stuff is really weird to typeâ entry in the series.â©
Not no hassle, though, and I look forward to a future where we can drop it, as Ember moves more and more toward modern JavaScript ways of solving these same problems!â©