Ember Concurrency and Typescript
Ember + TypeScript is an excellent combination that has really helped improve our team's output whilst maintaining very low levels of runtime bugs.
We're very heavy users of another library called ember-concurrency
which provides some fantastic opportunities to handle asynchronous work whilst providing clear user feedback that something is in progress.
Unfortunately, there's a challenge when using this approach with TypeScript and our codebase has ended up littered with // @ts-ignore
because TypeScript has some significant challenges understanding that the the decorated task returns something different to what's written in the generator function.
Luckily the Ember community is ace and there's now a very nice solution to this problem and I'm going to go through the process of converting a task to a type safe version.
I've provided a repo on Github should you wish to access the code from this post.
Dependencies
After starting a new ember project and installing ember-cli-typescript
, we're gonna add the following ember-concurrency
dependencies
- ember-concurrency - the base library needed for tasks.
- ember-concurrency-decorators - An extension that allows us to decorate our task.
- ember-concurrency-ts - Utilities that provide ways to let TypeScript know what's going on.
- ember-concurrency-async - Provides an alternative syntax for ember-concurrency tasks that works much better with TypeScript's ability to infer return types amongst other things.
So let's run ember install ember-concurrency ember-concurrency-decorators ember-concurrency-async ember-concurrency-ts
Finally add the imports below to your types/{repo-name}/index.d.ts
. This will ensure the compiler knows how to type some tasks and also where to import things from.
import 'ember-concurrency-async';
import 'ember-concurrency-ts/async';
The Old Way
We'll work with a very simple task here that simply yield
s a timeout. Here's the template
<h1>
Original Task
</h1>
<button type="button" {{on "click" (perform this.loadData)}}>
Run Task
</button>
<ul>
<li>
Task status - {{if this.loadData.isRunning "Running" "Idle"}}
</li>
</ul>
import Component from '@glimmer/component';
import { timeout } from 'ember-concurrency';
import { task } from 'ember-concurrency-decorators';
export default class OriginalTask extends Component {
@task
*loadData() {
yield timeout(1000);
}
}
All is good in the world until we want to call a task in JS/TS.
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { timeout } from 'ember-concurrency';
import { task } from 'ember-concurrency-decorators';
export default class OriginalTask extends Component {
@task
*loadData() {
yield timeout(1000);
}
@action
indirectInvocation() {
this.loadData.perform();
}
}
Now we see that we have an error.
Why The Fork Is This Error Showing
Under the hood, an ember-concurrency task is wrapping up your method in a computed property. So in this case loadData
as far as the compiler is concerned is only what is written in the generator function and it sure as hell doesn't return something with a method on it called perform
.
It also is unable to know about all the derived state, like isRunning
.
So in order to tell the compiler about the 'magic' that's happening under the hood we need to provide more information.
A Typed Task
There's a bunch of ways you can use the taskFor
approach from ember-concurrency-ts
, I'm only going to cover one version because I think it provides the best developer ergonomics.
We're working with a new template, the content is the same as the previous one except for the heading.
<h1>
Typed Task
</h1>
<button type="button" {{on "click" (perform this.loadData)}}>
Run Task
</button>
<ul>
<li>
Task status - {{if this.loadData.isRunning "Running" "Idle"}}
</li>
</ul>
And for our component class we'll use async
and provide extra information for the compiler with taskFor
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { timeout } from "ember-concurrency";
import { task } from "ember-concurrency-decorators";
import { taskFor } from "ember-concurrency-ts";
export default class TypedTask extends Component {
@task
loadData = taskFor(async () => {
await timeout(1000);
});
@action
indirectInvocation() {
this.loadData.perform();
}
}
- We use the same
@task
decorator. - We now use a class property assignment rather than a generator function (
loadData =
). - We wrap the assigned function in
taskFor
, this is where all the magic happens - We use an
async
arrow function, so we can refer tothis
in the function body without having to tell the compiler the type ofthis
. - In the function body we use
await
instead ofyield
.
Hovering over the perform
call in vs-code, we can see that the task is correctly inferred as a <void>
because it returns nothing. This is awesome!
And when we want to consume these tasks, our text editor can help us out with code completion too!