DEV Community

Pragmatic Maciej
Pragmatic Maciej

Posted on • Updated on

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.

Discussion (6)

Collapse
koalayt profile image
He Yuntao

I find another solution:

// Detail see: https://stackoverflow.com/questions/42123407/does-typescript-support-mutually-exclusive-types
type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };
type XOR<T, U> = (T | U) extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U;

class BothString {
    constructor(public a: string, public b: string) {}
}

class BothNumber {
    constructor(public a: number, public b: number) {}
}

type BothStringOrNumber = XOR<BothString, BothNumber>

// Now the function body is simple
function f (input: BothStringOrNumber) {
    if (input instanceof BothString) {
        return input.a + ':' + input.b;
    } else {
        return input.a + input.b
    }
}

// Behave as expect
f({a: 2, b: 3}); // correct usage
f({a: 1, b: 'a'}); // error!
f({a: 'a', b: 2}); // error!
f({a: 'a', b: 'b'}) // correct usage

Enter fullscreen mode Exit fullscreen mode
Collapse
jfet97 profile image
Andrea Simone Costa
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?

Collapse
macsikora profile image
Pragmatic Maciej Author

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
Collapse
mateiadrielrafael profile image
Matei Adriel

So basically no solution without modifying the body

Collapse
macsikora profile image
Pragmatic Maciej Author

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.

Collapse
shapovalovdmitry profile image
Shapovalov-Dmitry

Hello - I have a little note on the function overloads method - when the overloads are already defined, there is no need to write unions. Thanks a lot for these series of exercises :)