Previous: Decorators
As of version 1.2, ember-concurrency comes bundled with its own type definitions . On compatible verions of TypeScript – currently 3.7 and 3.9, it should automatically pick them up to provide type checking and completion.
import Component from '@ember/component'; import { TaskGenerator, task, timeout } from 'ember-concurrency'; 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 has 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 Classic 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 implementation. 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, task, timeout } from 'ember-concurrency'; 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()); }); } }
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 works 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.
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 relationship 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, task } from 'ember-concurrency'; 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, task } from 'ember-concurrency'; 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 relationship 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'; 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 have 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.
Previous: Decorators