Gernot Raudner
Gernot Raudner

Reputation: 824

Why does Typescript assume every branch in this switch statement to be "never"?

I'm trying to access the properties of an object via bracket notation. For this I created a const enum which represents a subset of the keys of said object. Now, depending on what key is used, I want to perform an operation or just return the correct value. However, for every branch in the switch statement (even the default branch!!), Typescript shows me this (of course for numbers it's number and for undefined in the default case it's undefined):

Type 'string' is not assignable to type 'UserInput[T]'.
  Type 'string' is not assignable to type 'never'.(2322)

Here's the code:

const enum UserInputInfo {
    Name = 'name',
    StartTime = 'start',
    ValidUntil = 'validUntil',
    Target = 'target',
    Url = 'url',
    Title = 'title'
}
interface UserInput {
    start: number;
    target: string;
    title: string;
    validUntil: number;
    url: string;
    name: string;
    getName: () => string;
    some: boolean;
    other: string;
    props: number;
}
// errors happen in each return line
function getUserInputInfo<T extends UserInputInfo>(infoType: T): UserInput[T] {
    const userInput: UserInput = null as any as UserInput; // this is just to simplify the code snippet
    switch (infoType) {
        case UserInputInfo.Name:
            return (userInput ? userInput.getName() : '');
        case UserInputInfo.ValidUntil:
            return (userInput ? userInput.validUntil : 0);
        case UserInputInfo.StartTime:
            return (userInput ? userInput.start : 0);
        case UserInputInfo.Target:
            return (userInput && userInput.target);
        case UserInputInfo.Url:
            return (userInput ? userInput.url : '');
        case UserInputInfo.Title:
            return (userInput ? userInput.title : '');
        default :
            return void 0;
    }
}

What is Typescript trying to tell me? How can I fix this issue? It seems the only workaround is to assert each return value to as UserInput[T].

Upvotes: 1

Views: 548

Answers (2)

jcalz
jcalz

Reputation: 329543

The issue here is because of a change introduced in TypeScript 3.5 to improve the soundness of writing to an indexed access type. In TypeScript 3.4 and below, your code would not have produced an error (well, except for returning undefined at the end, which the compiler doesn't consider unreachable). In no version of TypeScript (to date, up to TS3.8) is the compiler actually able to verify that your code is safe; it's just that in older versions the compiler would silently fail to catch actual errors (e.g., case UserInputInfo.Name: return 1) whereas in newer versions the compiler loudly fails to validate safe code like you've got.

The underlying problem is: you've got a generic value T extends UserInputInfo which is a union of (essentially) string literals, and you're using control flow (via switch/case) to try to figure out which T you've got and act accordingly. But the compiler does not use control flow analysis to narrow a generic type parameter T from UserInputInfo to, say, UserInputInfo.Name. There's a longstanding open GitHub issue about this, microsoft/TypeScript#24085. Not sure if there will ever be much traction there but you might want to go and give it a 👍 if you want to slightly increase the chance that it will be addressed.


Until then, we have workarounds. By far the easiest one is to use an overload so that the implementation of your function is intentionally more loosely typed. This is sort of like reverting to the pre-TS3.5 behavior for unions:

function getUserInputInfo<T extends UserInputInfo>(infoType: T): UserInput[T];
function getUserInputInfo(infoType: UserInputInfo): UserInput[UserInputInfo] {
    const userInput: UserInput = null as any as UserInput;
    switch (infoType) {
        case UserInputInfo.Name:
            return (userInput ? userInput.getName() : '');
        case UserInputInfo.ValidUntil:
            return (userInput ? userInput.validUntil : 0);
        case UserInputInfo.StartTime:
            return (userInput ? userInput.start : 0);
        case UserInputInfo.Target:
            return (userInput && userInput.target);
        case UserInputInfo.Url:
            return (userInput ? userInput.url : '');
        case UserInputInfo.Title:
            return (userInput ? userInput.title : '');            
    }
}

That suppresses compiler warnings, and isn't completely unsafe, since you can't return something nutty like {a: 1} when the compiler expects string | number. But if you return a number where there should be a string, or vice versa (e.g., case UserInputInfo.Name: return 1), it will happily compile (as in TS3.4-) and you'll get runtime errors (e.g., getUserInputInfo(UserInputInfo.Name).toUpperCase()).


Another workaround (mentioned in a comment by person who implemented the soundness change) is to take advantage of the fact that if you have a value t of type T, and a key k of type K extends keyof T, then the type of t[k] will be seen by the compiler as T[K]. So if we can make a valid UserInput, we can just index into it with infoType. Maybe like this:

function getUserInputInfo2<T extends UserInputInfo>(infoType: T): UserInput[T] {
    const userInput = null as UserInput | null;
    const validUserInput: UserInput = {
        get [UserInputInfo.Name]() { return userInput ? userInput.getName() : '' },
        get [UserInputInfo.ValidUntil]() { return userInput ? userInput.validUntil : 0 },
        get [UserInputInfo.StartTime]() { return userInput ? userInput.start : 0 },
        get [UserInputInfo.Target]() { 
          return (userInput && userInput.target) || '' }, // not sure
        get [UserInputInfo.Url]() { return userInput ? userInput.url : '' },
        get [UserInputInfo.Title]() { return userInput ? userInput.title : '' },
        some: false,
        other: "",
        props: 1,
        getName: () => ""
    }
    return validUserInput[infoType];
}

That's more or less a translation of your switch/case version into an object indexing operation (except that target might be false in your version, which is not a string). In actual code I'd expect that there'd be less of a mismatch between UserInput and UserInputInfo, or you could use Pick like this:

function getUserInputInfo3<T extends UserInputInfo>(
  infoType: T
): Pick<UserInput, UserInputInfo>[T] {
    const userInput = null as UserInput | null;
    const validUserInput: Pick<UserInput, UserInputInfo> = {
        get [UserInputInfo.Name]() { return userInput ? userInput.getName() : '' },
        get [UserInputInfo.ValidUntil]() { return userInput ? userInput.validUntil : 0 },
        get [UserInputInfo.StartTime]() { return userInput ? userInput.start : 0 },
        get [UserInputInfo.Target]() { 
          return (userInput && userInput.target) || '' }, // not sure
        get [UserInputInfo.Url]() { return userInput ? userInput.url : '' },
        get [UserInputInfo.Title]() { return userInput ? userInput.title : '' },
    }
    return validUserInput[infoType];
}

Okay, hope one of those helps you; good luck!

Playground link to code

Upvotes: 3

Roberto Zvjerković
Roberto Zvjerković

Reputation: 10157

Typescript is telling you that you're extending an enum, which can be any string.

So UserInput[anythingOtherThanDefinedEnumKeys] is never, because UserInput doesn't have all the keys.

You can just do:

function getUserInputInfo(infoType: UserInputInfo): UserInput[UserInputInfo] {}

Upvotes: 1

Related Questions