Nursing Code

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

  1. ember-concurrency - the base library needed for tasks.
  2. ember-concurrency-decorators - An extension that allows us to decorate our task.
  3. ember-concurrency-ts - Utilities that provide ways to let TypeScript know what's going on.
  4. 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 yields 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.

typescript-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();
  }
}
  1. We use the same @task decorator.
  2. We now use a class property assignment rather than a generator function (loadData = ).
  3. We wrap the assigned function in taskFor, this is where all the magic happens
  4. We use an async arrow function, so we can refer to this in the function body without having to tell the compiler the type of this.
  5. In the function body we use await instead of yield.

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!

type inferred

And when we want to consume these tasks, our text editor can help us out with code completion too!

code-completion