loading...

Advanced TypeScript Exercises - Answer 3

macsikora profile image Maciej Sikora Updated on ・4 min read

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

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 :(

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 :)

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

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

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 :)

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 :)

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.

Posted on by:

macsikora profile

Maciej Sikora

@macsikora

I am Software Developer, currently interested in static type languages (TypeScript, Elm, Reason) mostly in the frontend land. I am available for mentoring, I can help with type systems and FP.

Discussion

pic
Editor guide
 
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
  }
}

About this solution you asked: type string[] | number[] doesn't fully fit this function. Can you spot why, and what type would be better?

Let me try: is all about the length of the array? We need exactly two elements, so a tuple should be better, right?

 

Yes exactly, if we have static number of elements then we should use tuple type. Using number[] means we allow any number of arguments of type number.

function f(...[a, b]: number[]) { }
function g(...[a, b]: [number, number]) { }

f(1, 2, 3) // allowed
g(1,2,3) // only two allowed so error
 

So basically no solution without modifying the body

 

Yes agree that I could more think to avoid such. I wrote in the original question that some adjustments of the body are ok. But still I agree with you that it was unfortunate.