-
Notifications
You must be signed in to change notification settings - Fork 12.5k
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
Annotate immediately-invoked functions for inlining flow control analysis #11498
Comments
I suggested something similar in #7770 (comment). Though after thinking about it, I thought it was better to annotate callbacks as non-immediate #10180 (comment): Immediate case: function doSomething(callback: () => any) {
callback();
}
function fn(x: number|string) {
if (typeof x === 'number') {
doSomething(() => x.toFixed()); // No error.
}
} Non-immediate case: function doSomething(callback: async () => any) {
setTimeout(callback, 0);
}
function fn(x: number|string) {
if (typeof x === 'number') {
doSomething(() => x.toFixed()); // Error x is 'number|string'
}
} Sync callbacks is only assignable to sync callbacks and vice versa for Async callbacks: function doSomething(callback: () => any) {
setTimeout(callback, 0); // Error a sync callback is not assignable to an async one.
} function doSomething1(callback: () => any) {
callback();
}
function doSomething2(callback: () => any) {
doSomething1(callback); // No error
} Though re-using |
For reference, there was also some discussion of |
@RyanCavanaugh you commented here that implementing this kind of type modifier would require a massive architectural change. Is that still the case? |
For completeness #11463 proposed an alternative solution for this problem. Since that was just an "escape hatch" and this is an instruction to CFA to better understand the code, I would prefer this. The use case where we have run into this is converting a const noop = () => { };
function createDeferral() {
let complete = noop;
let cancel = noop;
const promise = new Promise<void>((resolve, reject) => {
complete = resolve;
cancel = reject;
});
return { complete, cancel, promise };
}
const deferral = createDeferral();
While it is more "burden" on the developer, in the sense of "strictness" I still think it is safer to assume the worst (that it might not be immediate). Like with |
Also 🚲 🏠 while it might be a tad bit confusing, why not interface PromiseConstructor {
new <T>(executor: sync (resolve: (value?: T | PromiseLike<T>) => void, reject: (reason?: any) => void) => void): Promise<T>;
} |
I forgot to mention my points on why I think annotating it as async rather sync.
Maybe to minimize confusion that |
I think to cover all cases of "used before assigned" checks you would need both a The |
What about the interplay between immediate vs deferred invocation and synchronous vs asynchronous functions, which can occur in any combination? function invokeImmediately<R>(cb: () => R) {
return cb();
}
let p = invokeImmediately(async () => {
console.log(1);
await null;
console.log(2);
});
console.log(3);
p.then(() => {
console.log(4);
});
// Output:
// 1
// 3
// 2
// 4 Here we have immediate invocation of an asynchronous function (fairly common in real code). As shown by the output, the async function executes synchronously until it either awaits or returns. Then the rest of the function is deferred to another tick. It would be pretty hard to do accurate CFA in this case with types and assignments involved, since some of the callback happens 'in line' with the surrounding code and some execution is deferred until later. |
In the
Not knowing anything about how CFA is actually implemented, presumably it could still make a prediction up until the first For both your points it would seem that |
@novemberborn I'm assuming though no annotation is immediate? |
How do you mean? |
Still think it would be dangerous to have implicit immediate. There are many callbacks where it doesn't matter and if it does matter, the developer should be explicit in order for CFA not to make assumptions. |
In below, my arguments so far have been that no annotation means sync: interface PromiseConstructor {
new <T>(executor: (resolve: (value?: T | PromiseLike<T>) => void /* is sync*/, reject: (reason?: any) => void) => void): Promise<T>;
} |
@kitsonk the CFA need to assume either implicit deferred or implicit immediate in case of no annotation. Or are you arguing for that either |
@tinganho I don't think this discussion is about how the callback behaves, it's about whether it's called before or after the function it's passed to has returned. |
It could be conservative and make neither assumption, consistent with present compiler behaviour. Otherwise all the existing code out there with no annotation would suddenly compile differently (i.e. assuming immediate invocation). Being explicit with |
No, it needs to assume what it assumes today, which is it could be either, therefore values could have changed, so reset the types. And picking an example of a let foo = { foo: 'bar' };
new Promise((resolve) => {
console.log('executor -->', foo.foo);
resolve(foo);
})
.then((result) => {
console.log('result -->', result.foo);
});
foo.foo = 'qat';
console.log('post -->', foo.foo);
/* logs
executor --> bar
post --> qat
result --> qat
*/ |
What's the difference between |
@tinganho currently this example has a "used before assigned" error under conservative (i.e. 'neither') assumptions. But CFA could compile this successfully if it knew that the callback would be invoked later (e.g. with a |
@yortus that was filed as a bug and fixed, the If you thought the bug should be there, then you are assuming an implicit immediate callback and not neither? I mean let say a callback can be both. Wouldn't it in all cases assume the async case since it is the worst case? Just for clarity I think when you say |
@tinganho yes you are right, I suppose the 'fix' there means the compiler now does assume deferred execution on the basis of pragmatism. But to be clear, it assumes deferred invocation, not whether or not the callback itself is asynchronous. |
I'm sure you have considered this at length but given the original example declare function mystery(f: () => void);
let x: string | number = "OK";
mystery(() => {
x = 10;
});
if (x === 10) { // Error, we believe this to be impossible
} I think the most intuitive behavior would be for a closure which rebinds As @kitsonk remarked
While I am in favor of what CFA brings especially in catching logic errors and redundant (superstitious) checks, I think the example above should not be an error because the caller is justified in checking x against function conditionalBuilder() {
return {
when: (predicate: () => boolean) => ({
then: (action: () => void) => {
if (predicate()) {
action();
}
}
});
};
}
conditionalBuilder()
.when(() => Math.random() * 100) % 2 > 1)
.then(() => {
x = 10;
}); EDIT fixed typo, formatting |
The useful distinction isn't between // Invoke N times when N may be 0
function f() {
let x: number
([] as number[]).map(n => {
x = n
})
console.log(x)
}
function provideDefault<T>(x: T | undefined, getDefault: () => T) {
return x === undefined ? getDefault() : x
}
// Invoke 0 or 1 times
function g(n: number | undefined) {
let x: number
const y = provideDefault(n, () => {
x = 0
return 0
})
console.log(x)
}
function createIterator<T>(next: () => IteratorResult<T>) {
return { next }
}
// Invoke later, but *not* asynchronously
function h() {
let x: number
const iter = createIterator(() => {
x = 0
return { value: undefined, done: true }
})
console.log(x)
} We correctly flag all of these currently. |
My bad updated the code its inside a Promise. I assume then its just like all the above people talking...
|
Workaround that worked for me: const _hack: { myMutableVariable: string | null } = {myMutableVariable: null}
immediateInvoke(() => { _hack.myMytableVariable = 'hello' })
_hack.myMutableVariable // type: string | null |
+1 to @rChaoz recommendation to "assume the worst" , narrowing the type such that it won't let you write proper code seems like a weird bug in TS |
@MartinJohns can you help me understand why this is a desirable experience?
Wouldn't it make more sense to preserve the type |
I read through some of the related comment threads and it appears as though this is not something TypeScript will fix due to it widening the types / removing narrowing hence breaking existing workflows. I saw some suggestions to add compilerOptions but this was rejected. I honestly rarely run into these issues with "real" code regardless and I would rather use a |
Workaround that worked for me: let x = "OK" as string | number; |
@rChaoz You're basically asking that TS be pessimistic in all cases - #9998 talks about this, but in summary: The compiler doesn't analyze the contents of called functions or passed callbacks for performance reasons (and sometimes doesn't even know what a called function does), and cancelling all narrowings every time any function is called would be impractical (people would just start putting type assertions everywhere) - hence the request for an Anyway... #56407 involves an |
For Perhaps
Since @RyanCavanaugh I assume that it's still the case that "reachability and control flow analysis is done before typechecking" as you mentioned in #9757 (comment). That probably means this is all pretty unrealistic, but even so it would be useful to agree on the ambition. Here's another example that illustrates why the current narrowing is bad: class Foo {
private n?: number;
async test() {
this.n = 123;
await new Promise(resolve => {
this.n = undefined;
setTimeout(resolve, 1000);
});
const n: number = this.n;
console.log(n);
}
} The narrowing of
|
Regarding the annotation using As a discussion, I would suggest considering some form of richer assertions about the function intent as part of their signature. My idea is to have some declare function mystery1(cb: () => void): void & asserts {
cb() // same as 'immediate always', but also says it is invoked exactly once
}
declare function mystery2(cb: () => void): void & asserts {
if (_) {
cb() // invoked optionally, but immediately (sync)
}
}
declare function mystery3(cond: boolean, cb: () => void): void & asserts {
if (cond) { // the condition can be used in CFA too
cb() // invoked optionally based on provided condition, but immediately (sync) and once only
}
}
mystery3(true, () => { ... }) // CFA knows it is called
mystery3(false, () => { ... }) // CFA knows it is not called
declare function mystery4(cb: () => void): Promise<void> & asserts {
await _
cb() // is called asynchronously, same as 'defer'
} The allowed code in the
|
@Igorbek Callback timing doesn't suffer from that much combinatorial explosion - there's only four possible combinations (the comment you're replying to misses one of them). And everything else ( Think of it this way:
These two aren't mutually exclusive on the surface:
For control flow checking, the last two have the same effect. Async doesn't imply timing at all, and so there's no use in allowing |
I would give you a hug if I could |
It appears that assert.equal(lastArgs?.type, 'dirty', 'setDirty/isDirty'); causes error error TS2339: Property 'type' does not exist on type 'never'. I think this is actually a bug in tsc: microsoft/TypeScript#11498 So this should be considered a workaround only. Alternative solution would be to annotate this line as @ts-ignore.
Per #9998 and its many offshoots, we have problems analyzing code like this
The problem is that we don't know if
mystery
invokes its argument "now" or "later" (or possibly even never!).The converse problem appears during reads:
Proposal is to allow some form of indicating that the function is invoked immediately, e.g.
This would "inline" the function body for the purposes of flow control analysis
The text was updated successfully, but these errors were encountered: