Advanced TypeScript Exercises - Answer 3

macsikora

Pragmatic Maciej

Posted on February 19, 2020

Advanced TypeScript Exercises - Answer 3

The question I have asked was how to type function arguments in order to have dependency in them, so if first is string then second needs to be string, never mixed, the original code snippet:

function f(a: string | number, b: string | number) {
    if (typeof a === 'string') {
        return a + ':' + b; // no error but b can be number!
    } else {
        return a + b; // error as b can be number | string
    }
}
f(2, 3); // correct usage
f(1, 'a'); // should be error
f('a', 2); // should be error
f('a', 'b') // correct usage
Enter fullscreen mode Exit fullscreen mode

There is not one possibility to solve the puzzle. Below few possible options.

Solution 1 - Simple generic type for both arguments

function f<T extends string | number>(a: T, b: T) {
    if (typeof a === 'string') {
      return a + ':' + b;
    } else {
      return (a as number) + (b as number); // assertion
    }
  }
// but usage is disappointing:
const a = f('a', 'b'); // och no the return is number | string :(
Enter fullscreen mode Exit fullscreen mode

Its nice and simple, we say we have one type for both arguments, therefor if first is string second also needs to be string. At the level of function api this is good solution, as all invalid use cases are now removed. The small issue here is need of assertion to number in else. But the big issue is, our return type is incorrect, it is not narrowed as we suppose it should 😪.

Fixing return type

function f<T extends string | number, R extends (T extends string ? string : number)>(a: T, b: T): R {
  if (typeof a === 'string') {
    return a + ':' + b as R;
  } else {
    return ((a as number) + (b as number)) as R;
  }
}
const a = f('a', 'b'); // a is string :)
const b = f(1, 2); // b is number :)
Enter fullscreen mode Exit fullscreen mode

As you can see solution is not so trivial and demands from us quite a lot of typing and assertion. We introduce here conditional type R which is now return type of our function, unfortunately we need to assert every return to this type. But the interface of the function is now perfect, arguments are type safe, and the return properly narrowed.

Solution 2 - Compose arguments into one type

// type guard
const isStrArr = (a: string[] | number[]): a is string[] => typeof a[0] === 'string'

function f(...args: string[] | number[]) {
   if (isStrArr(args)) {
     return args[0] + ':' + args[1];
   } else {
     return args[0] + args[1]; // no assertion
   }
 }
Enter fullscreen mode Exit fullscreen mode

This solution doesn't need even generic types. We compose our arguments into one type string[] | number[]. And it means that all all arguments will be string or all will be numbers. Because of no generic is used, there is no need of any assertion in the code. The issue is only fact that we need to provide additional type guard as pure condition doesn't narrow the type in else. The issue can be considered as using indexes instead of a, b directly, and this we cannot pass, we can destructure in if and in else, but this would not be any better. Why we cannot - because checking separately a would not effect type of b. Consider:

function f(...[a,b]: string[] | number[]) {
  if (typeof a === 'string') {
    return a + ':' + b; // b is number | string
  } else {
    return a + b; // error both are number | string
  }
}
Enter fullscreen mode Exit fullscreen mode

There is issue with type string[] | number[], it doesn't fully fit this function. Can you spot why, and what type would be better?

Also in this solution as we are not able to fix the return type, as we don't have generic type 🙄, it means return will always be string | number

Solution 3 - Generic compose type for arguments

// type guard
const isNumArr = (a: string[] | number[]): a is number[] => typeof a[0] === 'number'

function f<T extends string[] | number[], R extends (T extends string[] ? string : number)>(...args: T): R {
  if (isNumArr(args)) {
    return args[0] + args[1] as R;
  } else {
    return args[0] + ':' + args[1] as R
  }
}
const a = f('a', 'b'); // a is string :)
const b = f(1, 2); // b is number :)
Enter fullscreen mode Exit fullscreen mode

Solution 3 is similar to how we have fixed solution 2 by introducing return type R. Similarly here we need to also do assertion to R but we don't need to assert in else to number. As you can see what I did here is nice trick, I reversed condition and I ask firstly about numbers 😉.

Solution 4 - function overloads

function f(a: string, b: string): string
function f(a: number, b: number): number
function f(a: string | number, b: string | number ): string | number {
  if (typeof a === 'string') {
    return a + ':' + b;
  } else {
    return ((a as number) + (b as number));
  }
}
const a = f('a', 'b'); // a is string :)
const b = f(1, 2); // b is number :)
Enter fullscreen mode Exit fullscreen mode

By using function overloads we are able to create wanted arguments correlation, and proper return type. Overloads don't need generic types and conditional ones. IMHO overloads in that case are the simplest and the best solution.

Summary - all those solution are not ideal

In summary I want to say - don't do that, if you can, don't create such functions, much better would be to create two different functions, one working with string, and one with number. This kind of ad-hoc polimorhism we made here, maybe makes somebody happy, but it creates only complication. More about that in Function flexibility considered harmful.

The code for this answer can be found in the Playground.

This series is just starting. If you want to know about new exciting questions from advanced TypeScript please follow me on dev.to and twitter.

💖 💪 🙅 🚩
macsikora
Pragmatic Maciej

Posted on February 19, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related