Search code examples
typescriptcastingtype-inferencetypeguards

How to infer correct type when returning `payload is T` in typescript?


I have a type guard check function that does tells JavaScript that a value is a promise or not, and at the same time it tells TypeScript that the variable is a promise:

function getType (payload: any): string {
  return Object.prototype.toString.call(payload).slice(8, -1)
}    

function isPromise (payload: any): payload is Promise<any> {
  return getType(payload) === 'Promise'
}

It works really well, however, I realised I need to not hard code the promise resolve to any but infer that type instead.

This one example why I saw that any doesn't work properly:

export type PlainObject = { [key: string]: any }
let a: PlainObject | PlainObject[] | Promise<PlainObject | PlainObject[]>
let b = isPromise(a) ? await a : a

In this example, b is inferred as PlainObject while it SHOULD be PlainObject | PlainObject[]....

Question 1: Why is this happening?

My attempted solution at a better isPromise function:

type Unpacked<T> =
    T extends (infer U)[] ? U :
    T extends (...args: any[]) => infer U ? U :
    T extends Promise<infer U> ? U :
    T;

function isPromise2 <T extends any>(payload: T): payload is Promise<Unpacked<T>> {
  return getType(payload) === 'Promise'
}

In theory I don't see why this wouldn't work. But I get this error:

A type predicate's type must be assignable to its parameter's type. Type 'Promise>' is not assignable to type 'T'. 'Promise>' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'any'

Question 2: Why doesn't payload is Promise<Unpacked<T>> work and how could I infer this otherwise?

Here is everything above in the TypeScript PlayGround:

https://www.typescriptlang.org/v2/en/play?#code/KYDwDg9gTgLgBDAnmYcAKAbAhgSwHYDyARgFbADG8AvHAN5wDaA1sIgFxwDOMU+A5gF0OWPIjgBfAFCSAZgFc8lHBDxw+wGABVkqABRgsiDBCwATYaICUHbrzx86kuHCga5UVcTKUAdGCgQMIE6PkEAyjz8PuRYGBj6hsZmlj6cGDjkwLoAHAA0cAC0AIyWkuLOztLyijDKqjicaAEAtg16BkYm5nAiiNZwHUmmcA3oLW0APL0AfI7OrjDuqupaOgmdyXBU23AA5E0QrZzAu2XSGBo9HJi4hKQU8AA+6Nj4Xg8MAnDPB0fAEzc3vdKN8Xrd3pRPtNJBd4EQtiNGuNjrosJY4AB+HoAd1w8CwcGE0gA9MS4NiABZYeAASRcwFiGDEuLw8CCcFMEERbEkSBQcAAqngDOQWKYJppZlQnBVNHBQDBgHhTJw4Lp8DJgFBBZZPpjBYSZc45QqlSq1T5LVgoHxOBZEJ90VRZhqtQasQLDRU4CaQIrlarfpNXdqBbMPV7ZQBuKoKJQqRFB44AJjgEvlfrNqpm6yGHE0-UGXURY0OkyFIrFEums1oMoWSzUGm0KFzXSdO32yJOZyAA


Solution

  • 1: Why is this happening?

    PlainObject[] is a subtype of PlainObject. In set theory, when you have a union Subtype | Supertype, then the resulting type will be Supertype - it absorbs Subtype. So b gets type PlainObject here.

    Why is PlainObject[] a subtype? It gets clearer, when we look at Array and PlainObject types:

    interface Array<T> { [n: number]: T} // T is PlainObject for PlainObject[]
    type PlainObject = { [key: string]: any }
    

    In JS, number property key will become string. And no matter what T is, it is assignable to any.

    type PlainObjArr_extends_PlainObj  = PlainObject[] extends PlainObject ? true : false // true
    

    2: Why doesn't payload is Promise> work and how could I infer this otherwise?

    For custom type guards, the type predicate (is xxx) must be a subtype of the checked argument. TS cannot verify here, that Promise<Unpacked<T>> is a subtype of T. Neglecting that fact, isPromise does its job filtering out all incompatible values of a:

    let b = isPromise(a) ? await a /*Promise<PObj | PObj[]>*/ : a /*PObj | PObj[]*/
    

    any - as often - is the evil one :o). A solution is to make PlainObject[] incompatible to PlainObject:

    export type PlainObject = { [key: string]: unknown } // e.g. replace `any` by `unknown`
    let b = isPromise(a) ? await a : a  // b: PlainObject | PlainObject[]