1

Consider the following:


const STATES = ["Todo", "In Progress", "Blocked", "Done"] as const;


type State = typeof STATES[number]; // "Todo" | "In Progress" | "Blocked" | "Done"
type StateIndex = keyof typeof STATES; // number | keyof STATES

// so this works:
let goodIndex: StateIndex = 0;

// but so does this
let badIndex : StateIndex = 42;

In this example, STATES is completely known to the TS compiler, so its length is too (4). So why does the type of StateIndex evaluate to

number | keyof STATES

and not

0 | 1 | 2 | 3

?

I know that it's possible to define the types the other way:

type StateIndex = 0 | 1 | 2 | 3
type State = typeof States[StateIndex]

but doesn't the compiler have all the information it needs to evaluate to the literal type union of valid indices? Is there another way to nudge the type system to the right answer?

andrewdotnich
  • 16,195
  • 7
  • 38
  • 57

1 Answers1

4

typeof STATE is in fact a ReadonlyArray.

ReadonlyArray in turn has this interface:

interface ReadonlyArray<T> {
    readonly length: number;
    readonly [n: number]: T;
    // other methods
}

So, we know that general type number is allowed as index for immutable array/tuple.

Let's take a look on all keys of STATES:

const STATES = ["Todo", "In Progress", "Blocked", "Done"] as const;

type STATES = typeof STATES

type State = STATES[number]; // "Todo" | "In Progress" | "Blocked" | "Done"

type StateIndex = keyof (typeof STATES); // number | keyof STATES

type StateKeys = {
    [Prop in StateIndex]: Prop
}

enter image description here

As you might have noticed, along with expected keys 0 | 1 | 2 | 3, STATES also has other keys.

So, let's try to extract all number keys:

const STATES = ["Todo", "In Progress", "Blocked", "Done"] as const;

type STATES = typeof STATES

type State = STATES[number];

type StateIndex = keyof (typeof STATES);

type FilterNumbers<T extends PropertyKey> = T extends `${number}` ? T : never

// "0" | "1" | "2" | "3"
type Indexes = FilterNumbers<StateIndex>

We almost did it. But for some reason, TypeScript returns stringified keys instead of numerical values.

It is possible in generic way convert them to numbers.

type MAXIMUM_ALLOWED_BOUNDARY = 999

type ComputeRange<
    N extends number,
    Result extends Array<unknown> = [],
    > =
    (Result['length'] extends N
        ? Result
        : ComputeRange<N, [...Result, Result['length']]>
    )

type NumberRange = ComputeRange<MAXIMUM_ALLOWED_BOUNDARY>[number]

type FilterNumbers<T extends PropertyKey> = T extends `${number}` ? T : never

type ToNumber<T extends string, Range extends number> =
    (T extends string
        ? (Range extends number
            ? (T extends `${Range}`
                ? Range
                : never)
            : never)
        : never)

const STATES = ["Todo", "In Progress", "Blocked", "Done"] as const;

type STATES = typeof STATES

type State = STATES[number];

type GetNumericKeys<T extends PropertyKey> = ToNumber<FilterNumbers<T>, NumberRange>

// 0 | 1 | 2 | 3
type Result = GetNumericKeys<keyof STATES>

You can find more context and explanation in this question/answer and/or in my article

Playground

There is also another way to get allowed indexes from tuple.

const STATES = ["Todo", "In Progress", "Blocked", "Done"] as const;

type STATES = typeof STATES

type State = STATES[number];


type AllowedIndexes<
    Tuple extends ReadonlyArray<any>,
    Keys extends number = never
    > =
    (Tuple extends readonly []
        ? Keys : (Tuple extends readonly [infer _, ...infer Tail]
            ? AllowedIndexes<Tail, Keys | Tail['length']>
            : Keys
        )
    )

// 0 | 3 | 2 | 1
type Keys = AllowedIndexes<STATES>

You just recusively iterate over a tuple and push the length of current tuple to the Keys union

Playground

While first example might looks like an overengineering the problem it might be helpful in other use cases.

Just wanted to show different ways.