How can I unpack a return type that is a Typescript union and may be an array?

Viewed 972

I have a function with a return type that looks something like the following:

type FuncReturns = TypeA | [any, TypeB];

where...

interface TypeA {
    name: string;
    count: number;
}

interface TypeB {
    color: string;
    volume: number;
    edges: number;
}

const myAwesomeFunc = (val : string | number, obj : object = {}) : FuncReturns=>{
    switch(typeof val){
       case "string" : {
           return {
               name: val,
               count: 1
           }
       } case "number" : {
           return [obj, {
               color: "blue",
               volume: 6,
               edges: 6
           }]
       } default {// should never be used in this way
            throw new Error("This is nonsense.")
       }
    }
} 

... should be unpack-able (destructurable) both like this...

const {name, count} = myAwesomeFunc(someValue);

... and like this...

const [object, {color, volume, edges}] = myAwesomeFunc(someOtherValue);

Based on some internal logic, the function may return an object, TypeA, which I would like to unpack or an array, TypeB, which I would also like to unpack. Unfortunately, rather than accepting the array unpacking operator as one of the possible methods that could be applied to the return type, I end up with an error that states the union type is not an array type. I can think of several workarounds for my use case, but I'd like to know if there is an established way to solve this problem.

NOTE: As alekxsor duly noted, there are really only two branches I want the function to travel down: (1) string val to return object and (2) number val to return array. The optimal solution would enable typechecking such that when provided a string, the return type expected would be an object and so on.

2 Answers
type FuncReturns = TypeA | [any, TypeB]

const myAwesomeFunc = (val : string | number, obj : object = {}): FuncReturns {...}

This signature effectively telling that in concrete types it can do any of the following:

  (val: string, obj: ...) => TypeA
  (val: string, obj: ...) => [any, TypeB]
  (val: number, obj: ...) => TypeA
  (val: number, obj: ...) => [any, TypeB]

That is defenitily not the thing that it does in practice. The most concise way to convey the exact meaning is to use function overloading:

function myAwesomeFunc(val: string): TypeA
function myAwesomeFunc(val: number, obj: object): [any, TypeB]
function myAwesomeFunc(val: string | number, obj?: object): TypeA | [any, TypeB] {
  // actual implementation
}

Update for const version:

To make it work with const syntax you can define it either as a callable interface or as an intersection of function types:

interface MyAwesomeFunc {
  (val: string): TypeA
  (val: number, obj: object): [any, TypeB]
}

or

type MyAwesomeFunc 
  = (((val: string) => TypeA) 
  & ((val: number, obj: object) => [any, TypeB]))

const myAwesomeFunc: MyAwesomeFunc = (val: string | number, obj?: object): any {
 // ...
}

TS playground

You'll want to use a conditional type. Instead of noting val as string | number, mark it as a generic and then use a conditional type to note that the return type depends on the type of val.

const myAwesomeFunc = <T extends string | number>(val: T, obj: object = {}):
  T extends string ? TypeA : T extends number ? [any, TypeB] : never => {/* ... */};

This looks a bit complex, so let's break it down - we introduce a generic type parameter T that must be assignable to string | number (in practice, it must be either a string or a number). For the return type, we use a nested ternary:

T extends string
  ? TypeA /* if T is a string, we'll return TypeA */
  : T extends number
    ? [any, TypeB] /* if T is a number, we'll return an array with TypeB */
    : never /* this tells TS that this function can't return any other type */

I tried this on the TS playground and couldn't get it to compile correctly without using function overloading (see this SO thread for more context). Here's a working version:

function myAwesomeFunc<T extends string | number>(val: T, obj?: object): T extends string ? TypeA : T extends number ? [any, TypeB] : never;
function myAwesomeFunc<T extends string | number>(val: T, obj: object = {}): TypeA | [any, TypeB] {
  switch(typeof val){
    case "string" : {
      return {
        name: val,
        count: 1,
      };
    }
    case "number": {
      return [obj, {
        color: "blue",
        volume: 6,
        edges: 6,
      }];
    }
    default: {
      throw new Error("This is nonsense");
    }
  }
}

The first "overload" notes the conditional return type, and the second just marks the actual possible return types. Using this version, you can do

const { name, count } = myAwesomeFunc('Hello, world!');
const [obj, { color, volume, edges }] = myAwesomeFunc(123);

and everything should typecheck as expected.

Related