John
John

Reputation: 10079

How to restrict method argument to index number of tuple type?

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

Answers (4)

Patrick Roberts
Patrick Roberts

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

Playground Link

Upvotes: 2

0Valt
0Valt

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

0Valt
0Valt

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

Titian Cernicova-Dragomir
Titian Cernicova-Dragomir

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");

Play

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

Play

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


Play

Upvotes: 5

Related Questions