-
-
Notifications
You must be signed in to change notification settings - Fork 25
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
refactor: PoC for TypeScript type support #56
Conversation
f8fc7ba
to
05cbb1a
Compare
addon/ember-concurrency.d.ts
Outdated
Waiting = 'waiting' | ||
} | ||
|
||
export interface TaskInstance<T> extends PromiseLike<T>, Getter { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We may want to tweak this to be a union type where TaskInstanceState
and the various booleans are all always guaranteed/required to be the same.
interface TaskInstanceBase<T> extends PromiseLike<T>, Getter {
readonly hasStarted: boolean;
readonly isCanceled: boolean;
readonly isDropped: boolean;
readonly isFinished: boolean;
readonly isRunning: boolean;
readonly isSuccessful: boolean;
readonly value?: T;
cancel(): void;
catch(): RSVP.Promise<unknown>;
finally(): RSVP.Promise<unknown>;
then<TResult1 = T, TResult2 = never>(
onfulfilled?:
| ((value: T) => TResult1 | RSVP.Promise<TResult1>)
| undefined
| null,
onrejected?:
| ((reason: any) => TResult2 | PromiseLike<TResult2>)
| undefined
| null
): RSVP.Promise<TResult1 | TResult2>;
}
interface InstanceDropped {
readonly state: TaskInstanceState.Dropped;
hasStarted: true; // ?
isCanceled: true; // ?
isDropped: true;
isError: false; // ?
isFinished: true; // ?
isRunning: false; // ?
isSuccessful: false; // ?
}
interface InstanceCanceled {
readonly state: TaskInstanceState.Canceled;
hasStarted: true; // ?
isCanceled: true;
isDropped: false; // ?
isError: false; // ?
isFinished: true; // ?
isRunning: false; // ?
isSuccessful: false; // ?
};
// etc.
interface InstanceError {
isError: true;
error: unknown;
hasStarted: true; // ?
isCanceled: false; // ?
isDropped: false; // ?
isFinished: true;
isRunning: false;
isSuccessful: false;
}
interface Success {
isError: false;
error: undefined;
hasStarted: true; // ?
isCanceled: false; // ?
isDropped: false; // ?
isError: false;
isFinished: true;
isRunning: false;
isSuccessful: true;
}
export type TaskInstance<T> = TaskInstanceBase<T> & (
| InstanceDropped
| InstanceCanceled
| InstanceFinished
| InstanceRunning
| InstanceWaiting
| InstanceError
| InstanceSuccess
);
The // ?
s are for all the places where I don't remember from Ember Concurrency's docs what the actual behavior is – some of those may be boolean
always instead of a const
type like true
or false
.
export const task = createDecorator(createTaskFromDescriptor); | ||
const taskDecorator = createDecorator(createTaskFromDescriptor); | ||
|
||
export function task<Args extends any[], R>( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could use docs, of course!
I think I'm following this; why does it require both @task
and = task
, though? It seems like it would incur extra overhead?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@task
is what actually makes this a task at runtime.
= task(...)
is what makes this look like a task to TypeScript.
Unfortunately, either cannot do what the other one does, so we have to use this awkward double.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Technically = task(...)
can do @task
's job as well, using a babel transform. This is what I explored in ember-concurrency-typescript
. Unfortunately, I then hit buschtoens/ember-concurrency-typescript#6 with it, which made the whole thing kinda moot.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Got it, that's what I thought; how do we make it not incur runtime costs? Or is the judgment simply that they're small enough—one extra generator function allocation, it looks like?—that it doesn't really matter?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's not allocating an extra generator function, luckily! :)
= task(function*() {})
/ = task({ *perform() {} })
trigger this code branch:
ember-concurrency-decorators/addon/index.ts
Lines 219 to 226 in 66830d7
if ( | |
typeof firstParam === 'function' || | |
(typeof firstParam === 'object' && | |
// @ts-ignore | |
typeof firstParam.perform === 'function') | |
) | |
// @ts-ignore | |
return firstParam; |
This means that in this case, = task(...)
just passes through the generator function. So effectively, the following is equivalent:
@task
foo = task(function*() {});
// is equivalent to
@task
foo = function*() {};
This doesn't mean though, that we can't optimize this further with a Babel transform. ⚡️ 😄
@task
foo = task(function*() {});
// may be turned into
@task // from `ember-concurrency-decorators`
*foo() {}
// or even the following to avoid _any_ extra e-c-d runtime
@task(function*() {}) foo; // from `ember-concurrency`
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Incorrect rambling here
I need to correct myself. The following still does allocate a new generator function for every class instance:
@task
foo = function*() {}; // takes the `desc.initializer` path
While this one does not allocate a new generator function for every class instance:
@task
foo*() {} // takes the `desc.value` path
ember-concurrency-decorators/addon/index.ts
Lines 63 to 73 in 5abc1d5
function extractValue(desc: DecoratorDescriptor): any { | |
if (typeof desc.initializer === 'function') { | |
return desc.initializer.call(null); | |
} | |
if (typeof desc.get === 'function') { | |
return desc.get.call(null); | |
} | |
if (desc.value) { | |
return desc.value; | |
} | |
} |
I think. tbh I'm not 💯sure right now, when in the life cycle extractValue
is called. 😅
Edit: No, looks like desc.initializer
is only ever called once. Before the class is actually instantiated, which is also why it's called on null
.
|
||
export function task<Args extends any[], R>( | ||
taskFn: GeneratorFn<Args, R> | ||
): Task<Args, Exclude<R, Promise<any>>>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With TypeScript 3.6, this becomes much clearer / safer.
https://devblogs.microsoft.com/typescript/announcing-typescript-3-6-beta/#stricter-generators
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm so jazzed about that.
We should talk about TS version support at some point as well; it's probably fine to start with 3.6, given that and depending on when this lands and TS 3.6 lands, but the Typed Ember team is also working on hashing out a good ecosystem-wide versioning strategy as well to deal with the semi-regularity (and non-semver-compatible) breaking changes. Our current actual practice is "at least two most recent versions" but that's not going to work long-term.
// @TODO: this does not work | ||
perform: GeneratorFn<E, Args, R>; | ||
} | ||
>(encapsulatedTask: E): Task<Args, Exclude<R, Promise<any>>>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unfortunately this self-referential trickery does not work.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This can also be a blessing in disguise though!
class Foo {
@task
encapsulated = task(class {
privateState = 123;
*perform() {
return this.privateState;
}
});
}
This would solve #37.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
/cc @spencer516 FYI 😊
I published this as a pre-release |
Do any of you TypeScript whizzes know a way, preferably not requiring users of I thought putting it inside
|
I think this will work. |
Short update: We've been using this in production with great success for a few weeks now. The only remaining issue is providing the types to the consuming host application, which I still need to look into (#56 (comment)). |
I opened machty/ember-concurrency#319 to upstream the types from this PR. |
Can you guys please explain why you need this over using plain @(task(function * () {}).restartable())
myTask!; I believe, the code above:
You can even provide a custom return type: myTask!: Task<Product>; Why the above does not work for you and why do you want to change it for the code below? What's the gain? @restartableTask
myTask = task(function * () {}); |
Thanks for the question, @lolmaus – this is a good chance to explain for other folks as well.
Of these, (1), (3), and (4) are correct, but (3) and (4) are correct only in a trivial sense because (2) is quite wrong. There is no type at all here for TypeScript: it’s implicitly typed as What this means is that you have to specify the
The problem with doing as you suggest is that there’s a total decoupling between the type declaration and the actual function supplied. You can easily make a mistake even when first writing the task; more problematically, it’ll keep compiling just fine if you later need to make the yielded type of the What we actually want is to be able to have TypeScript check our code without our doing extra work when writing app code—that is, to do a bit of extra work on the library end so that users can just write fairly normal code and have the types always get checked and never get out of sync. That’s what this proposal does! |
Thank you for clarifications, @chriskrycho. |
any progress on this? ember-concurrency + typescript is the source of almost all my build errors |
@maxfierke @machty @chancancode IMHO we can now close this as https://github.com/chancancode/ember-concurrency-ts#alternate-usage-of-taskfor now provides the same functionality and is a migration path for anyone who jumped on |
🔮
This all is super messy and will require refactoring, but it proves the concept right. 🎉
The only remaining issue is the
this
context in encapsulated tasks, but I am happy to let that slide now and revisit it later.Closes #30. Kinda closes #41.
Kinda closes buschtoens/ember-concurrency-typescript#6 and makes the whole project obsolete. 😄