TypeScript and Ember.js Update, Part 1
How do things look in early 2018? Pretty good, actually!
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. →)
Back in July 2017, I wrote a post on how to using TypeScript in your Ember.js apps. At the time, we were still busy working on getting the typings more solid for Ember itself, and class
syntax for Ember was apparently a long way away.
Things have gotten quite a bit better since then, so I thought I’d update that post with recommendations for using TypeScript in an app now with the updated typings, as well as with another six months of experience using TypeScript in our app at Olo (~20k lines of code in the app and another ~15k in tests).
Here’s how I expect this update series to go:
- Overview, normal Ember objects, component arguments, and injections (this post).
- Class properties—some notes on how things differ from the
Ember.Object
world. - Computed properties, actions, mixins, and class methods.
- Using Ember Data, and service and controller injections improvements.
- Mixins and proxies; or: the really hard-to-type-check bits.
Normal Ember objects
For normal Ember objects, things now mostly just work if you’re using class-based syntax, with a single (though very important) qualification I’ll get to in a minute. And you can use the class-based syntax today in Ember.js—all the way back to 1.13, as it turns out. If you want to learn more, you can read this RFC or this blog post, both by @pzuraq (Chris Garrett), who did most of the legwork to research this and flesh out the constraints, and who has also been doing a lot of work on Ember Decorators.
Accordingly, I’m assuming the use of ES6 class
syntax throughout. The big reason for this is that things mostly just don’t work without it. And we’ll see (in a later post) some hacks to deal with places where parts of Ember’s ecosystem don’t yet support classes properly. In general, however, if you see an error like "Cannot use 'new' with an expression whose type lacks a call or construct signature."
, the reason is almost certainly that you’ve done export default Component.extend({...})
rather than creating a class.
A detailed example
That means that every new bit of code I write today in our app looks roughly like this, with only the obvious modifications for services, routes, and controllers—I picked components because they’re far and away the most common things in our applications.
In order to explain all this clearly, I’m going to start by showing a whole component written in the new style. Then, over the rest of this post and the next post, I’ll zoom in on and explain specific parts of it.
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"));
}
}
}
Component arguments
export default class AnExample extends Component {
// Component arguments
model: Person; // required
modifier?: string; // optional, thus the `?`
I always put these first so that the “interface” of the object is clear and obvious. You can do the same thing on a controller instance; in that case you would export a Model
from the corresponding Route
class and import it into the Controller
. It’s a bit of boilerplate, to be sure, but it lets you communicate your interface clearly to consumers of the Component
or Controller
.
An important note about these kind of arguments: you do not have to do this.get(...)
(or, if you prefer, get(this, ...)
) to access the properties themselves: they’re class instance properties. You can simply access them as normal properties: this.model
, this.modifier
, etc. That even goes for referencing them as computed properties, as we’ll see below.
For optional arguments, you use the ?
operator to indicate they may be undefined
. To get the most mileage out of this, you’ll want to enable strictNullChecks
in the compiler options.1 However, note that we don’t currently have any way to validate component argument invocation.[^ts-templates] The way I’ve been doing this is using Ember’s debug assert
in the constructor:
assert("`model` is required", !isNone(this.model));
import Component from "@ember/component";
import { Maybe } from "true-myth";
export default class MyComponent extends Component {
optionalArg?: string;
optionalProperty = Maybe.of(this.optionalArg);
}
Then if you invoke the property without the argument, it’ll construct a Nothing
; if you invoke it with the argument, it’ll be Just
with the value. [^ts-templates]: A few of us have batted around some ideas for how to solve that particular problem, but if we manage those, it’ll probably be way, way later in 2018.
Edit, January 24, 2018: Starting in TypeScript 2.7, you can enable a flag, --strictPropertyInitialization
, which requires that all declared, non-optional properties on a class be initialized in the constructor or with a class property assignment. (There’s more on class property assignment in part 2 of this series.) If you do that, all arguments to a component should be defined with the definite assignment assertion modifier, a !
after the name of the property, as on model
here:
export default class AnExample extends Component {
// Component arguments
model!: Person; // required
modifier?: string; // optional, thus the `?`
You should still combine that with use of assert
so that any misses in template invocation will get caught in your tests.
Injections
// -- Injections -- //
session: Computed<Session> = service();
Here, the most important thing to note is the required type annotation. In principle, we could work around this by requiring you to explicitly name the service and using a “type registry” to look up what the service type is – more on that below in my discussion of using Ember Data – but I’m not yet persuaded that’s better than just writing the appropriate type annotation. Either way, there’s some duplication. 🤔 We (everyone working in the typed-ember project) would welcome feedback here, because the one thing we can’t do is get the proper type without one or the other of these.
Edit, February 5, 2018: see Part 4 for some updates to this—I actually went ahead and built and implemented that approach, and everything is much nicer now.
// the current approach -- requires importing `Session` so you can define it
// on the property here
session: Computed<Session> = service();
// the alternative approach I've considered -- requires writing boilerplate
// elsewhere, similar to what you'll see below in the Ember Data section
session = service('session');
One other thing to notice here is that because TypeScript is a structural type system, it doesn’t matter if what is injected is the actual Session
service; it just needs to be something that matches the shape of the service – so your normal behavior around dependency injection, etc. is all still as expected.
That’s enough for one post, I think. In the next entry, we’ll pick up with how you handle class properties, including computed properties, and then talk about mixins as well. In the post after that, we’ll look at Ember Data and some related concerns.
This isn’t my preferred way of handling optional types; a
Maybe
type is. And you can, if you like, useMaybe
here:↩