Reputation: 10079
I have a generic class where the type parameter is a tuple. I'm having trouble creating a method on the class that has an argument restricted to an index of the tuple.
For example (playground link):
class FormArray<T extends [any, ...any[]]> {
constructor(public value: T) {}
// how to restrict `I` to only valid index numbers of `T` ?
get<I extends keyof T>(index: I): T[I] {
return this.value[index];
}
}
What I know you can do, is use keyof
to get all of the properties on the tuple, which will include the keys associated with the objects the tuple contains (i.e. "0", "1", etc). Unfortunately, keyof
pulls in all properties on the tuple, including "length", "splice", etc.
I tried using keyof
and excluding all properties which weren't of type number
, but then I realized that the index properties ("0", "1", etc) are returned by keyof
as type string
.
Is it possible to accomplish this currently in TypeScript?
Upvotes: 4
Views: 423
Reputation: 51936
You can obtain a union of numeric indices for a given tuple type using a conditional constrained inferred type within a template literal type like this:
type Indices<T extends any[]> =
Exclude<keyof T, keyof any[]> extends `${infer I extends number}`
? I
: never;
class FormArray<T extends [any, ...any[]]> {
constructor(public value: T) {}
get<I extends Indices<T>>(index: I): T[I] {
return this.value[index];
}
}
let a = new FormArray([1, "2", 3]).get("0"); // error
let b = new FormArray([1, "2", 3]).get("1"); // error
let c = new FormArray([1, 2, 3]).get(0); // number
let d = new FormArray([1, "2", 3]).get(1); // string
let e = new FormArray([1, "2", 3]).get(10); // error
Upvotes: 2
Reputation: 10355
To add to the Titian Cernicova-Dragomir's answer, the following is a workaround:
type ArrayKeys = keyof any[]; type StringIndices<T> = Exclude<keyof T, ArrayKeys>; interface IndexMap { "0": 0, "1": 1, "2": 2, "3": 3, "4": 4, "5": 5, "6": 6, "7": 7, "8": 8, "9": 9, "10": 10, "11": 11, "12": 12, } type CastToNumber<T> = T extends keyof IndexMap ? IndexMap[T] : number; type Indices<T> = CastToNumber<StringIndices<T>>; class FormArray<T extends [any, ...any[]]> { constructor(public value: T) {} get<I extends Indices<T>>(index: I): T[I] { return this.value[index]; } }
Here, we can successfully extract the property index numbers of the tuple if the tuple is of length 13 or less. Otherwise, we return the generic index
number
.
The above is credited to John
Upvotes: -1
Reputation: 10355
With an introduction of template literal strings, this can be solved in general form for arbitrary length tuples. The first step uses the same technique as the original solution by extracting tuple indices as a union of string literals:
type Indices<A extends any[]> = Exclude<keyof A, keyof any[]> & string;
The trick lies in the second step: instead of trying to cast strings to numbers, we do the inverse:
type numToStr<N extends number> = `${N}`;
Now, all we need to do is to Exclude
the manipulated index from the union of indices and check if the resulting type is assignable to the original union. If they are not - we have a valid index, if they are - the index is not a part of the original union:
type onlyValidIndex<A extends any[], I extends number> = Indices<A> extends Exclude< Indices<A>, numToStr<I> > ? never : I;
And voila! Applied to your situation, here is how you would use it:
class FormArray<T extends [any, ...any[]]> {
constructor(public value: T) {}
// how to restrict `I` to only valid index numbers of `T` ?
get<I extends number>(index: onlyValidIndex<T, I>): T[I] {
return this.value[index];
}
}
const fa = new FormArray([ 0, "", false ]);
let a = new FormArray([1, "2", 3]).get("0"); //error
let b = new FormArray([1, "2", 3]).get("1"); //error
let c = new FormArray([1, 2, 3]).get(0); //number
let d = new FormArray([1, "2", 3]).get(1); //string
let e = new FormArray([1, "2", 3]).get(10); //error
Note that you can't index with string literal versions of the indices anymore, but if it is needed, just lax the index constraint on to accept a number | string
union in helper types and number | Indices<T>
in get
method signature (as string
cannot index T
).
Upvotes: 1
Reputation: 250106
You can exclude keyof any[]
from keyof T
and be left with only the appropriate tuple keys, they will be in string form unfortunately:
class FormArray<T extends [any, ...any[]]> {
constructor(public value: T) {}
get<I extends Exclude<keyof T, keyof any[]>>(index: I): T[I] {
return this.value[index];
}
}
new FormArray([1,2, 3]).get("0");
You can also add a mapping to number, but it will have to be a manual affair I'm afraid:
interface IndexMap {
0: "0"
1: "1"
2: "2"
3: "3"
4: "4"
/// add up to a resonable number
}
type NumberArrayKeys<T extends PropertyKey> = {
[P in keyof IndexMap]: IndexMap[P] extends T ? P : never
}[keyof IndexMap]
class FormArray<T extends [any, ...any[]]> {
constructor(public value: T) { }
// how to restrict I to only valid index numbers of T ?
get<I extends Exclude<keyof T, keyof any[]> | NumberArrayKeys<keyof T>>(index: I): T[I] {
return this.value[index];
}
}
let a = new FormArray([1, "2", 3]).get("0");
let b = new FormArray([1, "2", 3]).get("1");
let c = new FormArray([1, 2, 3]).get(0); // number
let d = new FormArray([1, "2", 3]).get(1); // string
Note I am surprised T[I]
works even if I
is a number
even though keyof T
returns the indexes as string
not number
This realization lead me to another possible solution, where I
can also be a number
. If the number is in the tuple length range it will return the appropriate type, other wise it will return undefined
. It will not be an error on the invocation, but since the return value will be typed as undefined
if you use strictNullChecks
there is pretty much nothing you can do with it:
class FormArray<T extends [any, ...any[]]> {
constructor(public value: T) { }
// how to restrict I to only valid index numbers of T ?
get<I extends Exclude<keyof T, keyof any[]> | number>(index: I): T[I] {
return this.value[index];
}
}
let a = new FormArray([1, "2", 3]).get("0");
let b = new FormArray([1, "2", 3]).get("1");
let c = new FormArray([1, 2, 3]).get(0); // number
let d = new FormArray([1, "2", 3]).get(1); // string
let e = new FormArray([1, "2", 3]).get(10); // undefined
Upvotes: 5