Using ember-concurrency with TypeScript

ember-concurrency is an amazing tool for managing asynchronous tasks in Ember applications. Until recently, the TypeScript story for ember-concurrency has not been great with no official types, problems with type-safety, and reliance on a proof-of-concept only available in an alpha release. Recent work by Godfrey Chan and the author, with help from Max Fierke, Chris Krycho, and Dan Freeman, has greatly improved the experience of using ember-concurrency with TypeScript. This article will attempt to summarize this work, demonstrate what is now possible, and suggest some best practices.

ember-concurrency

Traditionally, ember-concurrency tasks would be defined in this way:

import { task } from 'ember-concurrency';

export default Component.extend({
  myTask: task(function*(bar) {
    const result = yield this.foo();
    return result ? result : bar;
  }).restartable(),
  
  // ⋮
}

Ember Octane introduced using native JavaScript classes to define controllers and components (among other things), where we typically find ember-concurrency tasks. TypeScript users have been using native classes in Ember for some time (it's been possible to use native classes in Ember for a while, but Octane made it officially supported and the default). There's a problem with using ember-concurrency the traditional way in native classes, though. Because tasks are computed properties, we can't just assign them to a class field. This doesn't work:

import { task } from 'ember-concurrency';

export default class MyComponent extends Component {
  myTask = task(function*(this: MyComponent, bar: string) {
    const result = yield this.foo();
    return result ? result : bar;
  });
  
  // ⋮
}

The problem is that ember-concurrency tasks, like all computed properties, must be defined on the class's prototype. Computed properties solve this by using a decorator (the computed export from @ember/object can be used both to define a computed property the classic way and to decorate a native getter). Fortunately, the ember-concurrency-decorators package was created to make this work for ember-concurrency.

ember-concurrency-decorators

ember-concurrency-decorators provides several decorators that can be used to decorate a generator method and turn it into an ember-concurrency task, e.g.

import { task } from 'ember-concurrency-decorators';

export default class MyComponent extends Component {
  @task
  *myTask(this: MyComponent, bar: string) {
    const result = yield this.foo();
    return result ? result : bar;
  };
  
  // ⋮
}

Great! Problem solved! But unfortunately, not for TypeScript. The problem is that decorators cannot change the type of the thing they decorate. As far as TypeScript is concerned, myTask is a generator function and therefore an expression like

this.myTask.perform();

is invalid because a generator function does not have a perform method.

Jan Buschtöns and I worked on a proof-of-concept for solving this issue that allows you write to something like:

import { task } from 'ember-concurrency-decorators';

export default class MyComponent extends Component {
  @task
  myTask = task(function*(this: MyComponent, bar: string) {
    const result = yield this.foo();
    return result ? result : bar;
  });
  
  // ⋮
}

Here task wraps the generator function and simply passes it through when used in this way. The task wrapper is inert at runtime, but it changes the type from a generator function to an ember-concurrency task such that it can be .perform()ed, etc. Jan even released this as an alpha version and some people have successfully used it in production apps. This approach has a few issues though. The usage of task as both a decorator and a wrapping utility function provides the convenience of a single import, but can be a bit confusing. Furthermore, the maintainers of ember-concurrency are hoping to eventually merge ember-concurrency-decorators into ember-concurrency such that the task import from ember-concurrency can also be used as a decorator. This is incompatible with the dual-usage of the task import because they want to continue to support the use of task() to define a task in Ember objects and other places where a decorator is inappropriate. Finally, the proof-of-concept introduced type definitions for ember-concurrency, but they had to be manually imported because they did not live in the ember-concurrency package.

A good bit of time passed where the alpha-release of ember-concurrency-decorators was really the only available solution that would give you some type-safety without jumping through hoops. Thankfully, Godfrey Chan tackled the problem and took the lead in getting official type definitions into ember-concurrency, creating ember-concurrency-ts, and creating ember-concurrency-async, which is not required for using TypeScript with ember-concurrency, but gives you better type-safety and has other advantages.

ember-concurrency-ts

The ember-concurrency-ts package provides a couple of utility functions for using TypeScript with ember-concurrency. The main function is taskFor(), which can be used one of two ways.

The first usage is as a way to access decorated task properties that changes the type from a generator function to an ember-concurrency task:

import { task } from 'ember-concurrency-decorators';
import { taskFor } from 'ember-concurrency-ts';

export default class MyComponent extends Component {
  @task
  *myTask(bar: string) {
    const result = yield this.foo();
    return result ? result : bar;
  }
  
  foo(): Promise<string | null> {
    // ⋮
  }
  
  get lastValue() {
    return taskFor(this.myTask).last.value;
  }
  
  @action
  doSomething(bar: string) {
    return taskFor(this.myTask).perform(bar);
  }
 }

There also exists a perform() utility function that is a shortcut for, e.g. taskFor(this.myTask).perform(bar). It could have been used above:

import { perform } from 'ember-concurrency-ts';

export default class MyComponent extends Component {
  // ⋮
  
  @action
  doSomething(bar: string) {
    return perform(this.myTask, bar);
  }
}

The second usage works similarly, but you use taskFor() at assignment when defining the task:

import { task } from 'ember-concurrency-decorators';
import { taskFor } from 'ember-concurrency-ts';

export default class MyComponent extends Component { 
  @task
  myTask = taskFor(function*(this: MyComponent, bar: string) {
    const result = yield this.foo;
    return result ? result : bar;
  });
  
  foo(): Promise<string | null> {
    // ⋮
  }
  
  get lastValue() {
    return this.myTask.last.value;
  }
  
  @action
  doSomething(bar: string) {
    return this.myTask.perform(bar);
  }
 }

This has the tradeoff of a slightly more complex task definition, but then you don't need to use a utility function to access the task. While both are perfectly acceptable, this author prefers the latter because it only requires a single usage of taskFor when defining the task and nothing extra when performing or accessing the task. There is a minor annoyance of having to type this if the task references this, but this can be eliminated with ember-concurrency-async (and you get better type-safety!).

ember-concurrency-async

The ember-concurrency-async package provides a Babel transform that allows you to define ember-concurrency tasks using async/await rather than generator functions:

import { task } from 'ember-concurrency-decorators';

export default class MyComponent extends Component {
  @task
  async myTask(bar: string) {
    const result = await this.foo();     
    return result ? result : bar;
  }
  
  foo(): Promise<string | null> {
    // ⋮
  }
  
  // ⋮
}

In addition to this syntax being more familiar to many JavaScript developers, it has some special advantages for TypeScript. First, generator functions are not well supported in TypeScript and cannot be made fully type-safe. As of now, all type information is lost through a yield because it always returns type any. In the examples in previous sections that use generator functions, the type of result is any. When using await, however, type information is preserved because await returns the resolved type of the promise it awaits. In the example above, the type of result is string | null. The second advantage is that, when using the taskFor utility from ember-concurrency-ts at assignment, you can use an async arrow function, eliminating the need to type this:

import { task } from 'ember-concurrency-decorators';
import { taskFor } from 'ember-concurrency-ts';

export default class MyComponent extends Component {
  @task
  myTask = taskFor(async (bar: string) => {
    const result = await this.foo();
    return result ? result : bar;
  });
  
  // ⋮ 
 }

This simplifies using taskFor at assignment while providing complete type-safety for ember-concurrency tasks! One note is that, currently, you will need to import a couple of additional type definitions. I'll also point out that the transform converts async arrow functions to non-arrow generator functions (arrow generator functions have been proposed but no Babel plugin exists for them at this time). The this context is bound, however, by the @task decorator, so this inside the async function will still refer to the containing class at runtime.

Summary

The ember-concurrency, ember-concurrency-decorators, ember-concurrency-ts, and ember-concurrency-async packages can be used together to provide a good experience using ember-concurrency with TypeScript. At some point in the future, some or all of these packages may be merged into the ember-concurrency package making the experience even nicer.

Here is documentation for each of these packages with regard to TypeScript:

I hope this has been helpful in demonstrating how to use ember-concurrency with TypeScript and the options available today. If you have any questions or corrections, please find me on the the Ember Community Discord where my handle is jamescdavis.