Here's code where the type of a promise disagrees with its resolved value:
const p: Promise<number> = Promise.resolve(1);
const q: Promise<string> = p.then<string>();
const r: string = await q;
// at this point, typeof r === 'number'
At this point, the type system says that r is a string, but the runtime behavior is that r is a number. I don't think .then<string>
is a type assertion.
Is this a case where the true runtime behavior of promises (specifically in the case of no provided callback) isn't possible to express in the typescript type system?
Note: the typing of .then() is from typescript/lib/lib.es5.d.ts:
interface PromiseLike<T> {
/**
* Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved.
* @param onrejected The callback to execute when the Promise is rejected.
* @returns A Promise for the completion of which ever callback is executed.
*/
then<TResult1 = T, TResult2 = never>(onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null): PromiseLike<TResult1 | TResult2>;
}
You've run into microsoft/TypeScript#58619, which hasn't been declared a bug or a design limitation yet, but it doesn't look like there's much to be done here.
The then()
method of Promise<T>
(which is what p
above is, although PromiseLike<T>
is very similar) is declared in the TypeScript library to be equivalent to this:
interface Promise<T> {
then<R1 = T, R2 = never>(
onfulfilled?: ((value: T) => R1 | PromiseLike<R1>) | null,
onrejected?: ((reason: any) => R2 | PromiseLike<R2>) | null
): Promise<R1 | R2>;
}
That's a single generic call signature which supports valid calls where the optional onfulfilled
and onrejected
callbacks are either both supplied, both omitted, or where onfulfilled
is supplied and onrejected
is rejected. The intended use of these is for the caller not to manually specify the R1
or R2
type arguments. Instead, you're supposed to just pass arguments and the compiler will infer them:
const p: Promise<number> = Promise.resolve(1);
const psb = p.then(n => "" + n, e => false);
// ^? const psb: Promise<string | boolean>
const ps = p.then(n => "" + n);
// ^? const ps: Promise<string>
const pn = p.then();
// ^? const pn: Promise<number>
In psb
, R1
is inferred as string
from the callback and R2
is inferred as boolean
from the callback, so you get Promise<string | boolean>
. In ps
, R1
is inferred as string
from the callback. But there is no inference site for R2
because the relevant callback is omitted. Inference therefore fails and therefore falls back to the default type argument of never
(that's what that T2 = never
means). So you get Promise<string | never>
which is just Promise<string>
. And for pn
, there is no inference site for R1
either. So R1
falls back to its default type argument of T
, which is number
. So you get Promise<number | never>
which is just Promise<number>
.
Those are all completely reasonable behaviors, right? If you call then()
and let TypeScript infer the type arguments, then everything works well. And this is generally how people actually do call then()
.
But even though that single call signature is convenient and works well to support intended use cases, it is technically wrong for the reason you found. Default type arguments can't prevent someone from manually specifying the type argument:
const q = p.then<string>(); // okay
// ^? const q: Promise<string>
Pretty much nobody does that. But something like the following has the same problem:
const qs: Promise<string> = p.then(); // okay
because TypeScript is using the return type as a contextual type for R1
and the same basic issue occurs.
The problem is that generic defaults don't take precedence over manually specified type arguments. They're not intended to. TypeScript lacks a way to express the situation where a default function argument should constrain a corresponding type argument. This is more or less the issue at the feature requests microsoft/TypeScript#49158 and microsoft/TypeScript#56315, except they also have to do with the difficulty of safely implementing functions using generic defaults.
For now the closest you can get is to abandon generic defaults and just use overloads to spell out exactly the intended behaviors for each function argument being missing or present. Something like this would maybe work for Promise
:
interface Promise<T> {
then(onfulfilled?: null): Promise<T>;
then<R1>(
onfulfilled: ((value: T) => R1 | PromiseLike<R1>),
onrejected?: null
): Promise<R1>;
then<R1, R2>(
onfulfilled: ((value: T) => R1 | PromiseLike<R1>),
onrejected: ((reason: any) => R2 | PromiseLike<R2>)
): Promise<R1 | R2>;
}
Now if you call then()
without any arguments, you cannot specify a type argument (the first call signature isn't even generic), so you get Promise<T>
. And if you call it with one argument, you can specify R1
, which must agree with onfulfilled
... but you cannot specify an R2
, so you get Promise<R1>
. Only if you call it with two arguments can you specify both R1
and R2
, and then these must agree with the callbacks. So from a caller's point of view these are the safest way to deal with it:
const q: Promise<string> = p.then<string>(); // error
But overloads are more complicated and have caveats. Inference in the face of overloads works poorly. Changing something so fundamental to the language will probably cause way too many real world breakages to be accepted. It's almost certainly the case that the cure is worse than the disease. If they triage microsoft/TypeScript#58619, I'd expect to hear that it's a design limitation and that people should just... not call then()
that way.