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
}

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.