Repeating/alternating function arguments

Viewed 73

Is it possible to specify a function with a repeating pattern of input arguments?

For example, if I want arguments to alternate between string and number

function someFunction(a: string, b: number){
  // 1 repetition
}
function someFunction(a: string, b: number, a2: string, b2: number){
  // 2 repetition
}
function someFunction(a: string, b: number, a2: string, b2: number, a3: string, b3: number){
  // 3 repetition
}
// and so on

It is of course possible to achieve something similar by a spread operator, but to my knowledge I cannot alternate the data type.

function someFunction(...someInput: (string|number)[]){
  // all input is now string|number, not string, then number, then string, and so on.
}
1 Answers

There are different approaches to this. Here's one which kind of works and kind of doesn't. There are possibly other ways to proceed also, but I'm pressed for time right now:

type SameLength<T> = { [K in keyof T]: any };
type Cyclic<T extends readonly any[], C extends readonly any[]> = readonly [] |
  (T extends [...SameLength<C>, ...infer U] ? readonly [...U, ...C] : readonly [...C]);

function someFunction<T extends readonly (string | number)[]>(
  ...args: T & Cyclic<T, [string, number]>
) { ... }

This should mostly work from the caller's side. The idea is that the args parameter list will be checked against a shifted version of itself. Cyclic<[A, B, C, D], [string, number] evaluates to [C, D, string, number]. If [A, B, C, D] and [C, D, string, number] are the same, it means that it must be [string, number, string, number].

Indeed, the function allows and disallows calls the way you want.

someFunction(); // okay
someFunction("a"); // error
someFunction("a", 1); // okay
someFunction("a", "b"); // error
someFunction("a", 1, "b", 2); // okay
someFunction("a", 1, "b", 2, "c", 3); // okay

Unfortunately the errors are not particularly informative, especially since they all show up on the first argument (which is a bug, microsoft/TypeScript#28505), and they complain about being assignable to never. This might be addressable but I don't know how at the moment.


Inside the implementation of the function, it's worse. The compiler does not know what type T will be and it can't reason generically that every other element will be a string and a number. All you get is this:

function someFunction<T extends readonly (string | number)[]>(
  ...args: T & Cyclic<T, [string, number]>
) {
  const a = args[0] // string
  const b = args[1] // number
  const c = args[2] // string | number 
}

So inside the function you need to use an assertion or something like it to let the compiler know that args[2*k] is a string | undefined and args[2*k+1] is a number | undefined.


So this is not polished enough for me to advise using it in production code.

Playground link to code


EDIT:

the following is a link to a slightly different formulation which has similar issues; the error messages are less opaque but it requires circular conditional types that won't be supported until TS4.1 and it's a bit more convoluted to explain. Not sure it's worth explaining this one when it's not much of an improvement over the other:

type StringNumberAlternating<L extends number,
  A extends readonly any[] = readonly []> = (string | number)[] & (
    undefined extends [...A][L] ? A['length'] extends L ? A :
    StringNumberAlternating<L, [string, number, ...A]> : A)

function someFunction<T extends StringNumberAlternating<T['length']>>(
  ...args: T
) { }

Playground link to code


UPDATE: you can also do it this way (in TS4.1+) where you pick a finite maximum argument list length (say, 20) and generate the union of relevant alternating tuples:

type SN<T extends readonly any[] = readonly []> = readonly [] | readonly [string, number, ...T]
type SNT<L extends number, A extends readonly any[] = readonly []> =
  A | (A['length'] extends L ? never : SNT<L, readonly [string, number, ...A]>);
type StringNumberTuples = SNT<20>; // max length of 20

function someFunction(...args: StringNumberTuples) {
  const a = args[0] // string | undefined
  const b = args[1] // number | undefined
  const c = args[2] // string | undefined
  const d = args[Math.floor(Math.random() * 100)] // string | number ‍♂️
}

This has good error messages (except they are still on the first argument all the time, due to microsoft/TypeScript#28505), and the function signature is no longer generic and is thus simpler. The main limitation is that you have to pick some maximum length to support; it's sort of like using overloads, except you can just pass a number like 20 instead of writing 10 overload signatures.

Playground link to code

Related