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