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:â©