Missing Implicit return type when returning an Enum member in Typescript

Viewed 77

Today I came across something interesting at work regarding the return type of a function when using Enum. I have tried to reproduce and make a simple example here.

const enum Fruits {
  Apple,
  Orange,
  Kiwi,
}

function getTest1() {
  return Fruits.Apple;
}

function getTest2(foo: boolean) {
  if (foo) {
    return Fruits.Orange;
  }

  return Fruits.Kiwi;
}

type test1ReturnType = ReturnType<typeof getTest1>;
type test2ReturnType = ReturnType<typeof getTest2>;

You can also play around with this example here: TS Playground

As you can see I would expect the test1ReturnType to be Fruits.Apple since test2ReturnType is correctly typed as the union of Fruits.Orange | Fruits.Kiwi. This is not the case, and it is actually returning Fruits, which makes absolutely no sense.

Why isn't it returning the obvious? I know how that adding as const in the getTest1 fn will fix this, but it shouldn't since getTest2 is correctly typed. I want to understand what's happening.

1 Answers

The underlying issue here is not specific to enums. It happens whenever you return a value of a single literal type from a function instead of a union of such types. For example, here is the analogous behavior for string literals:

function foo() { return "a"; }
// function foo(): string

function bar() { return Math.random() < 0.5 ? "a" : "z" };
// function bar(): "a" | "z"

The compiler uses heuristics to determine whether or not to widen a literal to its base type, based on assumptions of what behavior would most accurately conform to the expectations and desires of developers of real world code. These rules are described in microsoft/TypeScript#10676, the PR that implemented this functionality.

For example, compare the inferred types in the following lines:

const x = "a"; // const x: "a"
let y = "a"; // let y: string

Here the type of x is inferred as the string literal type "a", while the type of y is widened from "a" to string. The reasoning behind this difference is that you can't change the value of a const variable, but you can change the value of a let variable. The assumption is that the author of this code probably intends to change the value of y to some other string, otherwise they would have used const.

Of course heuristics are just heuristics; and they sometimes get things wrong. When the compiler's inference rules do something you don't like, your recourse is to express your intent explicitly, such as with a type annotation:

let z: "a" = "a"; // let z: "a"

Anyway, the specific situation you've run into is described in this comment. The compiler assumes that few people intentionally write functions that return a single literal type. A function implementation that always returns "a" or Fruits.Apple might plausibly be modified in the future to return a different string value or a different Fruit value... otherwise the author probably wouldn't have bothered making this a function in the first place. So the return type is widened. But if a function implementation returns a union of literal values, it's more likely that the author would be happy with the narrower type.

Again, this isn't always what people want, but is apparently close enough that it broke the least amount of real world code when this functionality was introduced. If you are unhappy with it, your recourse is again to be more explicit, such as with a return type annotation:

function baz(): "a" {
    return "a";
}

Playground link to code

Related