klaasman
klaasman

Reputation: 897

Get index type of an array literal

Assume I have the following array literal:

const list = ['foo', 'bar', 'baz'] as const;

I'm trying to generate a type which represents possible indices of this array. I tried to implement this as follows:

const list = ['foo', 'bar', 'baz'] as const;

type ListIndex = Exclude<keyof (typeof list), keyof []>;

but that ListIndex now becomes "0" | "1" | "2" instead of 0 | 1 | 2.

Can anyone tell me how to get the desired 0 | 1 | 2 type?

Upvotes: 6

Views: 3295

Answers (3)

bela53
bela53

Reputation: 3485

You can use the following type:

type Indices<T extends readonly any[]> = Exclude<Partial<T>["length"], T["length"]>

Let's test it with list:

type Test = Indices<typeof list> // Test = 0 | 1 | 2 // works!

Live playground code

Upvotes: 11

KRyan
KRyan

Reputation: 7598

There isn’t a built-in way to get this, which is pretty annoying. There are open issues looking for ways to handle this, but right now we have to work around it.

This is what I use to do that in my own project:

/** Type of the elements in an array */
type ElementOf<T> = T extends (infer E)[] ? E : T extends readonly (infer E)[] ? E : never;

/** Used internally for `Tail`. */
type AsFunctionWithArgsOf<T extends unknown[] | readonly unknown[]> = (...args: T) => any;

/** Used internally for `Tail` */
type TailArgs<T> = T extends (x: any, ...args: infer T) => any ? T : never;

/** Elements of an array after the first. */
type Tail<T extends unknown[] | readonly unknown[]> = TailArgs<AsFunctionWithArgsOf<T>>;

/** Used internally for `IndicesOf`; probably useless outside of that. */
type AsDescendingLengths<T extends unknown[] | readonly unknown[]> =
    [] extends T ? [0] :
    [ElementOf<ElementOf<AsDescendingLengths<Tail<T>>[]>>, T['length']];

/** Union of numerical literals corresponding to a tuple's possible indices */
type IndicesOf<T extends unknown[] | readonly unknown[]> =
    number extends T['length'] ? number :
    [] extends T ? never :
    ElementOf<AsDescendingLengths<Tail<T>>>;

With this, your code would look like

const list = ['foo', 'bar', 'baz'] as const;

type ListIndex = IndicesOf<typeof list>;

The idea behind this is that one way to get actual numbers that are related to the indices is to look into the T['length'] type (where type T = typeof list). That number, of course, will be 1 greater than the greatest index in the array—so if we can chop an element off of the array, the length will be the greatest index. Continue chopping until the array is empty, and each length as you go will be another index. So if you can recursively collect all the lengths as you pop elements off the tuple, you’ll get all the indices.

So in order to do that, we define Tail as the same tuple type except without the first element—I have also seen this type called Pop. But Typescript has limited options for manipulating tuples, so in order to implement Tail we need to use the tuple as the arguments of a function. That gives us more options for modifying it.

ElementOf does what it says, returns an element of the array. We use that to get a union instead of another tuple.

AsDescendingLengths actually performs the recursion and the getting of lengths. AsDescendingLengths<['foo', 'bar']> is [0, 1]. The ridiculous [ElementOf<ElementOf< in AsDescendingLengths is to get around Typescript’s attempts to prevent recursive types—which probably means that this is a really bad idea for particularly-long tuples, as recursive types are avoided for performance reasons. I have used it without issue on tuples of length 20 or so.

ElementOf and Tail are also useful in their own rights, of course. The function-argument manipulations also have some other uses for producing types like these. Hard to imagine AsDescendingLengths having any other use, but oh well. I personally put these in an ambient .d.ts file, so I don’t import them into files—which has the downside of polluting the scope with them. Up to you if that’s desirable or not.

Another example usage:

interface Array<T> {
    /**
     * Returns the index of the first occurrence of a value in an array.
     * @param searchElement The value to locate in the array.
     * @param fromIndex The array index at which to begin the search.
     * If `fromIndex` is omitted, the search starts at index 0.
     * Returns -1 if `searchElement` is not found.
     */
    indexOf<This extends T[], T>(this: This, searchElement: T, fromIndex?: IndicesOf<This>): -1 | IndicesOf<This>;
}

interface ReadonlyArray<T> {
    /**
     * Returns the index of the first occurrence of a value in an array.
     * @param searchElement The value to locate in the array.
     * @param fromIndex The array index at which to begin the search.
     * If `fromIndex` is omitted, the search starts at index 0.
     * Returns -1 if `searchElement` is not found.
     */
    indexOf<This extends readonly T[], T>(this: This, searchElement: T, fromIndex?: IndicesOf<This>): -1 | IndicesOf<This>;
}

Upvotes: 2

Maciej Sikora
Maciej Sikora

Reputation: 20132

What you get is the designed behavior. But we can achieve what you need by some mapping hack:

const list = ['foo', 'bar', 'baz'] as const;

type StrNum = {
  "0": 0,
  "1": 1,
  "2": 2,
  "3": 3,
  "4": 4 // if your tuple will be longer extend this for it
}

type MapToNum<A extends keyof StrNum> = {
  [K in A]: StrNum[K]
}[A] // this maps string keys into number by using StrNum type

type ListIndex = MapToNum<Exclude<keyof (typeof list), keyof []>>;
// result type 0 | 1 | 2

Upvotes: 1

Related Questions