Recently in RxJS Primitives I encountered a situation where one of the methods - concat was initially designed to take an argument list of strings and in the method used rest (...args) parameters, mimicking the signature and passing them to the String.prototype.concat

I’ve created a StackBlitz Project with the code for each step that can be followed along.

I wanted to refactor it to support an array of strings, but found that in the current implementation this is not possible and throws a TypeScript error:

1
2
3
4
5
export function concat(...args: string[]): MonoTypeOperatorFunction<string> {
  return (source: Observable<string>) => source.pipe(map(value => value.concat(...args)))
}

fromString('Testing').pipe(concat([' one', ' two'])).subscribe(console.log)
1
Argument of type 'string[]' is not assignable to parameter of type 'string'.

Due to how TypeScript treats rest parameters, it expects a list of parameters as a single string which is turned into an array-like arguments.

To get around this, we can use TypeScript function overloading.

How to overload functions with rest parameters

My first attempt at writing this method lead to this code which attempts to keep the type information in all implementations:

1
2
3
4
5
6
7
function concat(...args: string[]): MonoTypeOperatorFunction<string>;
function concat(args: string[]): MonoTypeOperatorFunction<string>;
function concat(...args: string[]): MonoTypeOperatorFunction<string> {
  return (source: Observable<string>) => source.pipe(map(value => value.concat(...args)))
}

export { concat }

This resulted in an error on the second implementation:

1
2
function concat(args: string[]): MonoTypeOperatorFunction<string> (+1 overload)
This overload signature is not compatible with its implementation signature.

It appears that TypeScript cannot convert an Array rest type to an arguments Array-like value, to get around this we need to use the any value in the last implementation:

1
2
3
4
5
6
7
function concat(...args: string[]): MonoTypeOperatorFunction<string>;
function concat(args: string[]): MonoTypeOperatorFunction<string>;
function concat(...args: any): MonoTypeOperatorFunction<string> {
  return (source: Observable<string>) => source.pipe(map(value => value.concat(...args)))
}

export { concat }

So now the TypeScript compiler stops complaining, and we can test it using both supported argument types:

1
2
fromString('Testing').pipe(concat(' one', ' two')).subscribe(console.log) // Testing one two
fromString('Testing').pipe(concat([' one', ' two'])).subscribe(console.log) // Testing one, two

However, if you look at the output of the result you’ll notice a bug in our Array implementation where a comma appears in the text - the issue is that now the implementation treats the array as the first argument in a list of arguments - and changing the last method to be args:any would remove our rest parameter destructing.

To solve this, we need to use the any type again to destructure our arguments and check for the first one being an array, if it is then we use this as our destructured arguments into the String.prototype.concat method, but if it’s a string then we pass all the arguments using destructuring:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
function concat(...args: string[]): MonoTypeOperatorFunction<string>;
function concat(args: string[]): MonoTypeOperatorFunction<string>;
function concat(...args: any): MonoTypeOperatorFunction<string> {
  const inArgs: any[] = [...args];
  if (inArgs[0] instanceof Array) {
    return (source: Observable<string>) => source.pipe(map(value => value.concat(...inArgs[0])))
  }
  return (source: Observable<string>) => source.pipe(map(value => value.concat(...inArgs)))
}

export { concat }

Now the implementation works as intended - our method can accept one or more string arguments, or an array of strings as the first argument - and it’s not possible to mix the two up - if you try add a second array to the arguments, the TypeScript compiler will look for single string arguments.