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.
Top comments (6)
I find another solution:
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 typenumber
.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.
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 :)