Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Implementing a generic function with a conditional return type

Tags:

typescript

I'm trying a very basic (contrived) conditional type function and getting unexpected errors:

function test<
  T
>(
  maybeNumber: T
): T extends number ? number : string {
  if (typeof maybeNumber === 'number') {
    return maybeNumber // Type 'T & number' is not assignable to type 'T extends number ? number : string'.
  }

  return 'Not a number' // Type '"Not a number"' is not assignable to type 'T extends number ? number : string'.
}

I thought that this was a pretty straightforward usage of a conditional type so not sure what's going on. Any ideas?

To clarify, I’m not really trying to implement this specific function. I’m just experimenting with conditional types and want to better understand why this doesn’t actually work.

like image 367
bingles Avatar asked Jan 24 '20 22:01

bingles


3 Answers

The underlying issue is that TypeScript's compiler does not narrow the type of a generic type variable via control flow analysis. When you check (typeof maybeNumber === "number"), the compiler can narrow the value maybeNumber to number, but it does not narrow the type parameter T to number. And therefore it cannot verify that it's safe to assign a number value to the return type T extends number ? number : string. The compiler would have to perform some analysis it currently does not do, such as "okay, if typeof maybeNumber === "number", and we inferred T from the type of maybeNumber alone, then inside this block we can narrow T to number, and therefore we should return a value of type number extends number ? number : string, a.k.a., number". But this doesn't happen.

This is quite a pain point with generic functions with conditional return types. The canonical open GitHub issue about this is probably microsoft/TypeScript#33912, but there are a bunch of other GitHub issues out there where this is the main problem.

So that's the answer to "why doesn't this work"?


If you're not interested in refactoring to get this to work, you can ignore the rest, but it might still be instructive to know what to do in this situation instead of waiting for the language to change.

The most straightforward workaround here that maintains your call signature is to make your function a single signature overload where the implementation signature is not generic. This essentially loosens the type safety guarantees inside the implementation:

type MyConditional<T> = T extends number ? number : string;
type Unknown = string | number | boolean | {} | null | undefined;

function test<T>(maybeNumber: T): MyConditional<T>;
function test(maybeNumber: Unknown): MyConditional<Unknown> {
  if (typeof maybeNumber === 'number') {
    const ret: MyConditional<typeof maybeNumber> = maybeNumber;
    return ret;
  }
  const ret: MyConditional<typeof maybeNumber> = "Not a number";
  return ret;
}

Here I've gone about as far as I can go to try to guarantee type safety, by using a temporary ret variable annotated as MyConditional<typeof maybeNumber> which uses the control-flow-analysis-narrowed type of maybeNumber. This will at least complain if you switch around the check (turn === into !== to verify). But usually I just do something simpler like this and let the chips fall where they may:

function test2<T>(maybeNumber: T): MyConditional<T>;
function test2(maybeNumber: any): string | number {
  if (typeof maybeNumber === 'number') {
    return maybeNumber;
  }
  return "Not a number";
}

Okay, hope that helps; good luck!

Playground link to code

like image 92
jcalz Avatar answered Oct 09 '22 10:10

jcalz


Correct answer and current workaround (at the time of writing):

type MaybeNumberType<T extends number | string> = T extends number
  ? number
  : string;

function test<T extends number | string>(
  maybeNumber: T,
): MaybeNumberType<T> {
  if (typeof maybeNumber === 'number') {
    return <MaybeNumberType<T>>(<unknown>maybeNumber);
  }

  return <MaybeNumberType<T>>(<unknown>'Not a number');
}

test(3); // 3
test('s'); // Not a number
like image 40
Lonely Avatar answered Oct 09 '22 09:10

Lonely


Your function would be better off if implemented with overloads:

function test(arg: number): number;
function test(arg: unknown): string;
function test(arg: any): string | number {
  if (typeof arg === 'number') {
    return arg;
  }

  return 'Not a number'
}
like image 22
Karol Majewski Avatar answered Oct 09 '22 08:10

Karol Majewski