Search code examples
typescriptgenericstypescript-typingstypescript-genericsconditional-types

How to type a generic function with conditional return type without casting?


Below is a simple example of a recursive function that attempts to “resolve” some abstract “pointers” to plain strings. Its return type is declared to return input type T for anything except a Pointer, whereas if it’s a Pointer then it would return a string.

Naively it seems like it should work, but for some reason TypeScript complains that Type ... is not assignable to type 'T extends Pointer ? string : ReplaceType<T, Pointer, string>'. [2322].

/**
 * If item ID is not known at conversion time,
 * a pointer can be provided to be resolved at import time.
 * (See `resolvePointersRecursively()`.)
 */
interface Pointer {
  predicate: string;
}

async function resolvePointersRecursively<T>(
  value: T,
): Promise<T extends Pointer ? string : ReplaceType<T, Pointer, string>> {
  if (isPointer(value)) {
    const foundItemID = await resolve(value.predicate);
    if (foundItemID) {
      return foundItemID;
      // ^ Type 'string' is not assignable to type 'T extends Pointer ? string : ReplaceType<T, Pointer, string>'. [2322]
      // Why? TS *knows* my T is a Pointer here, so why can’t I return a string?
    } else {
      throw new Error(`Unable to resolve pointer: no item found matching ${value.predicate}`);
    }
  } else if (value && typeof value === 'object') {
    if (Array.isArray(value)) {
      return await Promise.all(value.map((v: any) => resolvePointersRecursively(v)));
      // ^ same error, but with any[] instead of string.
    } else {
      // Assume plain non-array object.
      for (const [key, v] of Object.entries(value)) {
        value[key as keyof typeof value] = await resolvePointersRecursively(v);
      }
      return value;
      // ^ same error, but with T instead of string.
    }
  } else {
    return value;
    // ^ same error, but with T instead of string.
  }
}

function isPointer(v: any): v is Pointer {
  ...
}
async function resolve(predicate: string): Promise<string> {
  ...
}

It’s possible to work around this by simply casting all returned values to any, but I’d like to avoid that if possible.

For the record, the ReplaceType definition comes from T. J. Crowder’s answer, but that’s not where the problem is—it doesn’t matter if I substitute ReplaceType<T, Pointer, string> with simply T, the error is the same (just with T instead of ReplaceType<...>).


Solution

  • Currently control flow analysis only affects the apparent type of values, not the apparent constraint of generic type parameters. So while checking isPointer(value) has an effect on value, it has no effect whatsoever on T. That means the compiler has no idea whether foundValueID is of type T extends Pointer ? string : ReplaceType<T, Pointer, string>.

    This situation makes it essentially impossible to return a value of a generic conditional type without using a type assertion (or some other way of loosening type checks), and as such it's a missing feature of TypeScript, requested at microsoft/TypeScript#33912.

    The main reason this hasn't already been implemented is that it turns out to be quite tricky to get right. It seems "obvious" that if you have a value t of generic type T and you determine that t is of type U, then that means T extends U. But it's not necessarily so. The only thing you know for certain is that T and U have at least one value in common. That is, T & U is not never. For example, if you determine isPointer(value), all you know is that T & Pointer is not never. For example:

    interface MyType {
      predicate: string | number;
      foo: string;
    }
    const val: MyType = {
      predicate: Math.random() < 0.5 ? "abc" : 123,
      foo: "abc"
    }
    const x: MyType = await resolvePointersRecursively(val);
    

    Here MyType is a supertype of Pointer. And thus T extends Pointer ? string : ReplaceType<T, Pointer, string> evaluates to ReplaceType<MyType, Pointer, string> which is just MyType. So according to your return type annotation you'll get a MyType but you might actually be returning a string. This possibility of mismatch is a problem for both your typings and for any simple implementation of microsoft/TypeScript#33912.

    If you're not worried about such a situation then you can just use a type assertion, but you're doing so to circumvent a real type safety hole, not just to appease a compiler that's not clever enough to see obvious truths. There are indeed situations where the "obvious" thing actually is true, but for now there hasn't been any major effort to identify them. Until and unless anything changes, I'd say you should just use type assertions (or a single-call-signature overload) and move on.

    Playground link to code