Programming Notes

Narrow Multiple Return Types in TypeScript

Andrew Philips
4 min readMay 1, 2023
Image of two hands — one holding an orange and the other holding an apple.
Orange or Apple?
// 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

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
}

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

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.

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;
}

Here’s a TS Playground showing the error and the overload.

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

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

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;
}

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 and let me know what you think.

--

--