Search code examples
typescriptasync-awaitpromisees6-promise

Is the type of PromiseLike<T> wrong?


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>;
}

Solution

  • 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.

    Playground link to code