// 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. 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;
}
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 below and let me know what you think.
Top comments (1)
This is a great explanation of type guards and narrowing! 👍