Typing Your Ember, Part 3
How to actually use types effectively in Ember today.
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. â)
In the first of this series, I described how to set up a brand new Ember.js app to use TypeScript. In the second part, walked through adding TypeScript to an existing Ember.js app. In this part, Iâm going to talk about using TypeScript effectively in a modern Ember.js app.
Heavy lifting, so-so results
Letâs get this out of the way up front: right now, using types in anything which extends Ember.Object
is going to be a lot of work for a relatively low reward. Ember.Object
laid the foundation for the modern JavaScript class system (and thus the TypeScript class system), but it has a huge downside: itâs string keys and referennces all the way down. This kind of thing is just normal Ember codeâand note all the string keys:1
import Component from '@ember/component';
import { computed, get } from '@ember/object';
export default Component.extend({
someProperty: 'with a string value',
someOther: computed('someProperty', function() {
const someProperty = get(this, 'someProperty');
return someProperty + ' that you can append to';
}),
});
What this comes out toâeven with a lot of the very helpful changes made to TypeScript itself in the 2.x series to help support object models like this oneâis a lot of work adding types inline, and having to be really, really careful that your types are correct. If that property youâre Ember.get
-ing can ever be undefined
or null
, youâd better write the type as string | void
instead of just string
. For example: this code is written with the correct types:
import Component from '@ember/component';
import { computed, get } from '@ember/object';
export default Component.extend({
someProperty: 'with a string value', // no type annotation
someOther: computed('someProperty', function() {
const someProperty: string = get(this, 'property');
return someProperty + ' that you can append to';
}),
});
Note two important things about it, however:
- TypeScript does not (and, with the current typings for Ember, cannot) figure out the type of
someProperty
from this definition;get
currently just hands backany
as the type of these kinds of things. That type annotation is necessary for you to get any mileage out of TypeScript at all in a computed property like this. - If, anywhere in your code, you set the value of
someProperty
âincluding toundefined
ornull
, or to{ some: 'object' }
âthis could fail.
Unfortunately, this second point means that TypeScript actually canât guarantee this the way weâd like. Thereâs hope coming for this in the future in several waysâmore on that in a momentâbut for now, Iâll summarize this by saying TypeScript is really helpful within a function, once youâve correctly defined the types youâre using. That means that you have to continue to be very careful in what youâre doing in the context of any Ember.Object
instance, including all the Ember types which descend from Object
, and therefore also any types you define which extend those in turn.
Future niceties
In the future, weâll be able to get away from a lot of these difficulties by way of two changes coming down the line: Ember embracing ES6 classes to replace its current custom object system, and embracing decorators as a way of replacing the current approach to computed properties. Letâs take those in turn.
class
syntax
When Ember was birthed in the early 2010s (first as âSproutCore 2â and then âAmber.jsâ and finally âEmber.jsâ), the JavaScript world was a remarkably different place. The current pace of change year to year is nothing short of astounding for any language, but doubly so for one that sat languishing for so long. When Ember came around, something like todayâs class
syntax was unimaginable, and so essentially every framework had its own class system of some sort. Over the past few years, with the proposal and standardization of the class
syntax as nice sugar for JavaScriptâs prototypal inheritance, the need for a custom object and inheritance model has essentially gone away entirely. However, Ember doesnât do breaking changes to its API just because; we as a community and the core team in particular have chosen to place a high priority on backwards compatibility. So any adoption of ES6 classes had to work in such a way that we got it without making everyone rewrite their code from scratch.
All of this impacts our story with TypeScript because, well, TypeScript for a long time couldnât even begin to handle this kind of complexity (itâs a lot for a static type system to be able to express, given how very dynamic the types here can be). As of TS 2.3, it can express most of this object model, which is great⦠but itâs forever out of step with the rest of the JS/TS ecosystem, which is not so great. ES6 classes are first-class items in TypeScript and the support for getting types right within them is much, much stronger than the support for the mixin/extension style object model Ember currently uses. So moving over to ES6 classes will make it much easier for TS to do the work of telling you youâre doing it wrong with that classâand most importantly, itâll be able to do that automatically, without needing the incredibly hairy type definition files that weâre still trying to write to get Emberâs current model represented. It Will Just Work. That means less maintenance work and fewer places for bugs to creep in.
Gladly, weâre getting there! Already today, in the most recent versions of Ember, you can write this, and it will work:
import Component from '@ember/component';
export default class MyComponent extends Component {
theAnswer = 42;
andTheQuestionIs =
"What is the meaning of life, the universe, and everything?";
}
When I say âit will work,â I mean you can then turn around and write this in your my-component.hbs
and itâll be exactly what you would expect from the old Ember.Component.extend()
approach:
{{andTheQuestionIs}} {{the Answer}}
There is one serious limitation of that today: you canât do that with a class you need to extend further. So if, for example, you do like we do and customize the application route rinstance and then reuse that in a couple places, youâll still have to use the old syntax:
import Route from '@ember/route';
export default Route.extend({
// your customizations...
});
But everywhere you consume that, you can use the new declaration:
import ApplicationRoute from 'my-app/routes/application';
export default class JustSomeRoute extends ApplicationRoute {
model() {
// etc.
}
}
Thereâs more work afoot here, too, to make it so that these restrictions can go away entirely⦠but those changes will undoubtedly be covered in considerable detail on the official Ember blog when they roll out.
Decorators
Now, thatâs all well and good, but it doesnât necessarily help with this scenario:
import Component from '@ember/component';
import { computed, get } from '@ember/object';
export default class MyComponent extends Component {
someProperty = 'is just a string';
someOtherProperty = computed('someProperty', function() {
const someProperty = get(this, 'someProperty');
return someProperty + ' and now I have appended to it';
});
}
Weâre back in the same spot of having unreliable types there. And again: some really careful work writing type definitions to make sure that computed
and get
both play nicely together with the class definition would help somewhat, but⦠well, itâd be nice if the types could just be determined automatically by TypeScript. (Also, thereâs an open bug on the TypeScript repository for trying to deal with computed
; suffice it to say that computed as it currently stands is a sufficiently complicated thing that even with all the incredible type machinery TS 2.1, 2.2, and 2.3 have brought to bear on exactly these kinds of problems⦠it still canât actually model computed
correctly.)
For several years now, Rob Jackson has maintained [a small library] that let you write computed properties with decorators. Up till recently, those were incompatible with TypeScript, because they used to work in the context of object literals rather than classesâand TypeScript never supported that. However, as of about a month ago as Iâm writing this, theyâve been updated and they do work with ES6 classes. So, given the class syntax discussed above, you can now ember install ember-decorators
and then do this:
import Component from '@ember/component';
import { computed } from 'ember-decorators/object';
export default class MyComponent extends Component {
someProperty = 'with a string value';
@computed('someProperty')
someOther(someProperty: string) {
return someProperty + ' that you can append to';
}
}
Here, we can provide a type on the parameter to someOther
, which at a minimum makes this enormously cleaner and less repetitive syntactically. More interestingly, however, we should (though no one has done it just yet, to my knowledge) be able to write a type definition for @computed
such that TypeScript will already know that someProperty
here is a string, because itâll have the context of the class in which itâs operating. So that example will be even simpler:
import Component from '@ember/component';
import { computed } from 'ember-decorators/object';
export default class MyComponent extends Component {
someProperty = 'with a string value';
@computed('someProperty')
someOther(someProperty) {
return someProperty + ' that you can append to';
}
}
And in that imagined, wonderful future world, if we tried to do something that isnât a valid string operationâsay, we tried someProperty / 3
âTypeScript would complain to us, loudly.
Although this is still a future plan, rather than a present reality, itâs not that far off. We just need someone to write that type definition for the decorators, and weâll be off to the races wherever weâre using the new ES6 class approach instead of the existing Ember.Object
approach. So: soon. I donât know how soon, but soon.
Current ameliorations
In the meantime, of course, many of us are maintaining large codebases. I just checked, and our app (between the app itself and the tests) has around 850 files and 34,000 lines of code. Even as those new abilities land, weâre not going to be converting all of them all at once. And we want to get some real mileage out of TypeScript in the meantime. One of the best ways Iâve found to do this is to take a step back and think about the pieces of the puzzle which Ember is solving for you, and which it isnât. That is, Ember is really concerned with managing application state and lifecycle, and with rendering the UI. And itâs fabulous about those things. What itâs not particularly concerned with (and what it shouldnât be) is the particulars of how your business logic is implemented. And thereâs no particular reason, especially if most of that business logic is implemented in terms of a bunch of pure, straightforward, input-to-output functions that operate on well-defined data types, for all of your business logic to live in Ember.Object
-descended classes.
Instead, we have increasingly chosen to write our business logic in bog-standard TypeScript files. These days, our app has a lib
directory in it, with packages like utilities
for commonly used tools⦠but also like billing
, where we implement all of our client-side billing business logic. The display logic goes in the Ember.Controller
and Ember.Component
classes, and the routing and state management goes in the Ember.Route
and Ember.Data
pieces as youâd expect. But none of the business logic lives there. That means that weâre entirely free of the aforementioned constraints for the majority of the time dealing with that data. If we do a good job making sure the data is good at the boundariesâroute loads, for example, and when we send it back to the serverâthen we can effectively treat everything else as just boring old (new?) TypeScript.
So far weâve only taken that approach with about a quarter of our app, but itâs all the latest pieces of our app, and it has been incredibly effective. Even once weâre able to take advantage of all those shiny new features, weâre going to keep leaning heavily on this approach, because it lets Ember do what Ember is best at, and keeps us from coupling our business logic to the application state management or view rendering details.
Conclusion
So thatâs the state of things in Ember with TypeScript today. Your best bet for getting real mileage out of TypeScript today is to use the new class syntax support and decorators wherever you can within Ember-specific code, and then to write as much of your business logic outside the Ember system as possible. Gladly, all of that points you right at the future (in the case of syntax) and just good practice (in the case of separating out your business logic). So: not too shabby overall. Itâs working well for us, and I hope it does for you as well!
Next time: how we got here with the ember-cli-typescript
compiler, and where we hope to go from here!
Note that here and throughout, Iâm using the RFC #176 Module API, which you can use today via this polyfill.â©