DEV Community

Cover image for Narrowing Multiple Return Types in TypeScript
Andrew Philips
Andrew Philips

Posted on

Narrowing Multiple Return Types in TypeScript

// multiple return types
function getFruit({withCitrus: boolean}): Apples | Oranges;

// Why can't we write this?! We know Oranges are returned
var fruit: Oranges = getFruit({ withCitrus: true }); // error
Enter fullscreen mode Exit fullscreen mode

Function getFruit fetches us a bag of Apples or Oranges. At compile time, as developers we know the function returns Oranges because we asked for them. Unfortunately, the compiler doesn't know this. To fix the error, the standard recommendation is to use a Type Guard to narrow the type. This requires extra and seemingly unnecessary code.

// type guard distinguishes between Apples and Oranges
function isOranges(fruit: Apples | Oranges): fruit is Oranges {
  return "VitaminC" in (fruit as Oranges);
}

var fruits: Apples | Oranges = getFruit({withCitrus: true);
if (isOranges(fruits)) {
  // type guard narrowed the type to Oranges
  var oranges: Oranges = fruits; // ok, no compilation error
}
Enter fullscreen mode Exit fullscreen mode

The thing is we know the returned fruit type at compile time; why should we use a type guard? Can we skip it? To do that, we need to rewrite the getFruit function.

In this article Maurer Krisztián shows how to use a Generic to connect a function's arguments to its return type.

type FruitType<T> =
  T extends { withCitrus: false } ? Apples :
  T extends { withCitrus: true | undefined } ? Oranges :
  Oranges;

function getFruit<T extends { withCitrus: boolean }>(opt?: T): FruitType<T>;

var apples:   Apples  = getFruit({withCitrus: false}); // ok
var oranges1: Oranges = getFruit({withCitrus: true});  // ok
var oranges2: Oranges = getFruit();                    // ok
Enter fullscreen mode Exit fullscreen mode

Using Krisztián's formulation, we see at compile time how options passed to getFruit narrow the return type to that of the expected type. This is a great start, let's continue building upon it.

Avoid Type Casting

When we implement the function definition returning FruitType<T> we find it requires an internal type cast (ugh). If we add an overload function, we can continue to narrow the return type while separating it from the implementation. Here's a TS Playground showing the error and the overload.

interface IOptions { withCitrus?: boolean };
const DEFAULT_FRUIT_OPTS = { withCitrus: true } as const;

function getFruit<T>(opt?: T extends IOptions ? T : never): FruitType<T>;
function getFruit(opts?: IOptions)
{
  const _opts = { ...DEFAULT_FRUIT_OPTS, ...opts };
  return _opts.withCitrus ? bagOfOranges : bagOfApples;
}
Enter fullscreen mode Exit fullscreen mode

Allow Other Options

Adding another option does not interfere with return type narrowing.

interface IOptions {
    withCitrus?: boolean,
    hasColor?: Apples['color'] | Oranges['color'],
};

var apples:   Apples  = getFruit({withCitrus: false, hasColor: "red"});   // ok
var oranges1: Oranges = getFruit({withCitrus: true, hasColor: "orange"}); // ok
var oranges2: Oranges = getFruit({hasColor: "red"});                      // ok
Enter fullscreen mode Exit fullscreen mode

Can We Always Narrow?

Regardless of how well we refine the TypeScript, there are still some situations when the return type cannot be narrowed.

var opts5 = { withCitrus: false };          // boolean
var apple5: Apples = getFruit(opts);        // error

var opts6: IOptions = { withCitrus: true }; // boolean
var orange6: Oranges = getFruit(opts);      // error

var opts7 = { withCitrus: false } as const; // false
var apple7: Apples = getFruit(opts);        // ok
Enter fullscreen mode Exit fullscreen mode

When assigning an object to a variable, TypeScript generalizes the object. opts5 gets type { withCitrus: boolean } and therefore the return type is Apples | Oranges. This is the same result for opts6. For opts7, we use as const to convert the object to a type literal, and we once again have return type narrowed.

Edge Case Typing

We have a couple of potential edge cases with the type definition of FruitType<T> that can cause some bugs: (1) single source of truth for the default option value and (2) triggering a Distributive Conditional Type.

See this TS Playground for a deeper explanation.

Final Form

Tying it all together.

type FruitType<T> =
  T extends { withCitrus?: infer B } ?
    [boolean] extends [B] ? Oranges | Apples :
    B extends undefined | typeof DEFAULT_FRUIT_OPTS.withCitrus ?
    Oranges : Apples :
  Oranges;

function getFruit<T>(opt?: T extends IOptions ? T : never): FruitType<T>;
function getFruit(opts?: IOptions)
{
  const _opts = { ...DEFAULT_FRUIT_OPTS, ...opts };
  return _opts.withCitrus ? bagOfOranges : bagOfApples;
}
Enter fullscreen mode Exit fullscreen mode

Find the complete code and tests for return type narrowing in this TS Playground.

In this article we explain how to narrow a function's multiple return types using its argument list; we show how to inform the compiler which return type the caller expects. We do this in a type safe manner and avoid superfluous type casting. We encourage extra steps to avoid bugs when changing types and defaults. Finally, we explore various edge cases where return type narrowing requires extra focus by using type literals or type guards.

Please comment below and let me know what you think.

Top comments (1)

Collapse
 
brense profile image
Rense Bakker

This is a great explanation of type guards and narrowing! 👍