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:
- ember-concurrency - Using ember-concurrency with TypeScript
- ember-concurrency-decorators - TypeScript support
- ember-concurrency-ts - Readme
- ember-concurrency-async - 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
.