Using ember-concurrency with TypeScript

As of version 1.2, ember-concurrency comes bundled with its own type definitions . On compatiable verions of TypeScript – currently 3.7 and 3.9, it should automatically pick them up to provide type checking and completion.

Note that old versions of ember-concurrency-decorators came with an incomplete set of type definitions for ember-concurrency. If you are using that addon, be sure to upgrade to version 2.0 or above to avoid conflicts.

Classic Ember

import Component from '@ember/component';
import { task, timeout } from 'ember-concurrency';

export default Component.extend({
  myTask: task(function * (ms: number) {
    yield timeout(ms);
    return 'done!';
  }),

  performTask() {
    if (this.get('myTask').isRunning) {
      return;
    }

    this.get('myTask').perform(1000).then(value => {
      console.log(value.toUpperCase());
    });
  }
});

In Classic Ember, everything work as expected. Note that while from Ember's perspective this.get() is no longer required, it is needed here to get the correct type.

From what TypeScript can see, this.myTask is a TaskProperty but this.get('myTask') "unwraps" the task property into a Task, allowing perform() to be called on it, among other things. This is a limitation of @types/ember and is true for other computed properties as well.

Octane

import Component from '@ember/component';
import { TaskGenerator, timeout } from 'ember-concurrency';
import { task } from 'ember-concurrency-decorators';
import { taskFor } from 'ember-concurrency-ts';
export default class extends Component {
  @task *myTask(ms: number): TaskGenerator<string> {
    yield timeout(ms);
    return 'done!';
  }

  performTask() {
    if (taskFor(this.myTask).isRunning) {
      return;
    }

    taskFor(this.myTask).perform(1000).then(value => {
      console.log(value.toUpperCase());
    });
  }
}

Since we are using native classes in Octane, TypeScript have an easier time understanding and following our code. Normally, this is a good thing, but in the case of ember-concurrency, it ends up getting a bit in the way.

ember-concurrency's API was designed with Class Ember in mind, where it could decorate a property or method and replace it with a different type in the .extend() hook.

This is not allowed using TypeScript's decorators. Since myTask is defined using as the (generator) method syntax, and since methods do not have a .perform() method on them, calling this.myTask.perform() will result in a type error, even though it will work at runtime.

We could work around this by type casting the method, such as (this.myTask as any as Task<string, number>), but doing this everywhere is quite verbose and error-prone. Instead, we opted to use the ember-concurrency-ts addon, which encapsulate the type cast transparently in a taskFor utility function.

In addition to the code readability improvements, this utility function is also safer than the inline type cast, as it is able to automatically infer the arguments and return type of the task based on the task function signature.

The taskFor utility function also works with Encapsulated Tasks:

import Component from '@ember/component';
import { TaskGenerator, timeout } from 'ember-concurrency';
import { task } from 'ember-concurrency-decorators';
import { taskFor } from 'ember-concurrency-ts';

export default class extends Component {
  @task myTask = {
    foo: 'foo',

    *perform(ms: number): TaskGenerator<string> {
      console.log(this.foo); // => 'foo'
      yield timeout(ms);
      return 'done!';
    }
  }

  performTask() {
    if (taskFor(this.myTask).isRunning) {
      return;
    }

    taskFor(this.myTask).perform(1000).then(value => {
      console.log(value.toUpperCase());
    });
  }
}

Limitations of Generator Functions in TypeScript

In ember-concurrency, tasks are defined with generator function (method) syntax. The task can yield any objects, typically promises or other TaskInstance, and the ember-concurrency runtime will resolve it and resume the task with the resolved value when it becomes available.

Due to limitations in TypeScript's understanding of generator functions, it is not possible to express the realtionship between the left and right hand side of a yield expression. As a result, the resulting type of a yield expression inside a task function must be annotated manually:

import Component from '@ember/component';
import { TaskGenerator } from 'ember-concurrency';
import { task } from 'ember-concurrency-decorators';
import { taskFor } from 'ember-concurrency-ts';
import { JSON } from './utils';

export default class extends Component {
  @task *fetchData(url: string): TaskGenerator<JSON> {
    let response: Response = yield fetch(url);
    let data: JSON = yield response.json();
    return data;
  }

  performTask() {
    taskFor(this.fetchData).perform('/api/data.json').then(data => {
      console.log({ data });
    });
  }
}

Since the yield expressions are typed to "return" any, TypeScript would happily go along with any type annotation and allow you to freely assign the result of the yield expression. Therefore, it is very important to ensure it matches up with reality.

A safer approach would be to use a utility type, like so:

import Component from '@ember/component';
import { TaskGenerator } from 'ember-concurrency';
import { task } from 'ember-concurrency-decorators';
import { taskFor } from 'ember-concurrency-ts';
import { JSON } from './utils';

type Resolved<T> = T extends PromiseLike<infer R> ? R : T;

export default class extends Component {
  @task *fetchData(url: string): TaskGenerator<JSON> {
    let fetchPromise = fetch(url);
    let response: Resolved<typeof fetchPromise> = yield fetchPromise;

    let dataPromise = response.json();
    let data: Resolved<typeof dataPromise> = yield dataPromise;

    return data;
  }

  performTask() {
    taskFor(this.fetchData).perform('/api/data.json').then(data => {
      console.log({ data });
    });
  }
}

This Resolved utility type will unwrap a yieldable value into its resolved type. This is much safer, as it retains the types realtionship between the left and right hand side of the yield expressions.

A third approach to this solving this problem is to use the ember-concurrency-async addon, which enables async method syntax for tasks:

import Component from '@ember/component';
import { task } from 'ember-concurrency-decorators';
import { taskFor } from 'ember-concurrency-ts';
import { JSON } from './utils';

export default class extends Component {
  @task async fetchData(url: string): Promise<JSON> {
    let response = await fetch(url);
    let data = await response.json();
    return data;
  }

  performTask() {
    taskFor(this.fetchData).perform('/api/data.json').then(data => {
      console.log({ data });
    });
  }
}

await expressions in async methods already has the Resolved semantics we are after, natively built into the TypeScript compiler. Using this syntax, we automatically get the correct types out-of-the-box.

In order for this code to type-check, we will have to include the following in types/<app name>/index.d.ts:

import 'ember-concurrency-async';
import 'ember-concurrency-ts/async';

This imports the type extensions form the addons to add a few new utility types as well as to add new overload signatures to existing functions to support async task functions.