Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Typescript: Type 'string' is not assignable to type 'never' in return value of function

Tags:

typescript

Basically I'm interested why does this example work:

interface Alpha {}
interface Beta {}

interface HelloMapping {
  'Alpha': Alpha
  'Beta': Beta  
}

const hello = <T extends keyof HelloMapping>(arg: T): HelloMapping[T] => 
{
  if (arg === 'Alpha') return {} as Alpha;
  return {} as Beta;
};

const a = hello('Alpha') // a is of interface Alpha

But this one doesn't:

interface HelloMapping2 {
  'Alpha': string
  'Beta': number
}

const hello2 = <T extends keyof HelloMapping2>(arg: T): HelloMapping2[T] => 
{
  if (arg === 'Alpha') return "42" as string
  return 42 as number
};

It errors with Type 'string' is not assignable to type 'never' in the first line of function code. The only difference is changing interfaces to string and number types

Please note, I know how to make that work with function overloads but I am curious why it does not work.

like image 803
apieceofbart Avatar asked Oct 24 '19 10:10

apieceofbart


1 Answers

The first one works for the reason pointed out by @zerkms: Alpha and Beta are the same type. TypeScript's type system is structural, not nominal. If Alpha and Beta have the same shape (i.e., the empty type {}), they are the same type, despite having different names. So let's ignore that example because it rests on an accidental type identity.


The reason the second example won't work is that generics do not get narrowed by control flow analysis (see microsoft/TypeScript#24085). The check arg === 'Alpha' doesn't convince the compiler that T is now 'Alpha'. It's generally unsound to do so; if there are multiple possible values of type T, then checking one value of type T doesn't necessarily have implications on any other.

There's currently no clean solution for this, although suggestions have been made that might address it. One suggestion is to allow you to specify that a generic type must be exactly one literal member of a union, so that any values of type T that exist must be identical, and you can safely narrow all of them when you narrow one.

If you want to see this implemented or the situation addressed, you might want to head over to those GitHub issues and give them a 👍 or describe your use case if it's particularly compelling.

So, what to do in the meantime?


The answer that always works to allow the implementation to compile with out error is to use type assertions or overloads (they are what I call "morally" equivalent, since overloads essentially assert the types of parameters and returns to be the those in the implementation signature). In your case they would look like this (I know you know how to do this, but others who come later might not):

const helloAssert = <T extends keyof HelloMapping>(arg: T): HelloMapping[T] => {
    if (arg === 'Alpha') {
        return "42" as HelloMapping[T];
    }
    return 42 as HelloMapping[T];
};

function helloOverload<T extends keyof HelloMapping>(arg: T): HelloMapping[T];
function helloOverload(arg: keyof HelloMapping) {
    if (arg === 'Alpha') {
        return "42"
    }
    return 42;
}

These are somewhat unsafe, though, because although they'd catch completely crazy errors (e.g., return true), they wouldn't catch simple mixups (e.g., change === to !== in the test).


Sometimes, though, you can avoid these unsafe solutions by abandoning control flow analysis and represent the generic type manipulation directly: an object o of type O and a key k of type K yields a readable property o[k] of type O[K], even if O and K are generic:

const helloMap = <T extends keyof HelloMapping>(arg: T): HelloMapping[T] =>
    ({ Alpha: "42", Beta: 42 })[arg];

So you give an actual HelloMapping instance to helloMap() and index into it. This might not work for all use cases, of course, but it's sometimes useful to do things in a way that works with the compiler instead of against it.


Link to code

like image 53
jcalz Avatar answered Sep 22 '22 03:09

jcalz