Write an assert function that tags an array so only an index derived from that tag may read its elements. Every other index becomes a type error.
A for loop with a counter is the one place where TypeScript stops checking your array reads.
assertArrayIndex(array, key) is an assert function that tags an array with a unique string key. After the call, the only index allowed to read elements out of that array is one produced by Index<typeof array>. A plain number, a literal, or the counter belonging to a different array all stop compiling, which turns the classic nested-loop slip into something the compiler can see:
const matrix = [
[3, 4],
[5, 6],
[7, 8],
]
for (let i = 0; i < matrix.length; i += 1) {
const columns: number[] = matrix[i]
for (let j = 0; j < columns.length; j += 1) {
const current: number = columns[i] // i instead of j, and nobody complains
}
}columns[i] runs off the end of every row, yet number[][] is happy to hand back a number. The upstream question reaches for noUncheckedIndexedAccess to catch this, which makes every read T | undefined and forces a check you already know is redundant. The type below gets there without the flag: the safe read stays narrow, and everything else stops compiling.
Sometimes we want to use the good old for-loop with an index to traverse the array, but in this case TypeScript does not check in any way that we are accessing the elements of the array at its real index (not exceeding the length of the array), and that we are not using an arbitrary number as an index, or index from another array (for nested loops, for traversing matrices or graphs):
const matrix = [
[3, 4],
[5, 6],
[7, 8],
];
// This example contains no type errors when the noUncheckedIndexedAccess option is off.
for (let i = 0; i < matrix.length; i += 1) {
const columns: number[] = matrix[i];
for (let j = 0; j < columns.length; j += 1) {
const current: number = columns[i]; // oops! i instead of j
console.log(
current.toFixed(), // TypeError: Cannot read property 'toFixed' of undefined
);
}
}You can enable the noUncheckedIndexedAccess option (in tsconfig.json), but then each time you access an array element, you will need to check that this element exists, which is somewhat verbose and inconvenient, especially since in the case of such a for-traversal, we are sure that the index does not exceed the length of the array:
const numbers = [5, 7];
for (let i = 0; i < numbers.length; i += 1) {
const current = numbers[i];
if (current !== undefined) {
console.log(current.toFixed());
}
}Write an assert-function assertArrayIndex(array, key) that can be applied to any array (with an arbitrary unique string key, which is needed to distinguish arrays at the type level) to allow access to the elements of this array only by the index obtained from array by the special generic type Index<typeof array> (this functionality requires enabling the noUncheckedIndexedAccess option in tsconfig.json):
const numbers = [5, 7];
assertArrayIndex(numbers, 'numbers');
for (let i = 0 as Index<typeof numbers>; i < numbers.length; i += 1) {
console.log(numbers[i].toFixed());
}When accessing by such an index, it must be guaranteed that an element in the array exists, and when accessing an array by any other indices, there is no such guarantee (the element may not exist):
const matrix = [
[3, 4],
[5, 6],
[7, 8],
];
assertArrayIndex(matrix, 'rows');
let sum = 0;
for (let i = 0 as Index<typeof matrix>; i < matrix.length; i += 1) {
const columns: number[] = matrix[i];
// @ts-expect-error: number | undefined in not assignable to number
const x: number[] = matrix[0];
assertArrayIndex(columns, 'columns');
for (let j = 0 as Index<typeof columns>; j < columns.length; j += 1) {
sum += columns[j];
// @ts-expect-error: number | undefined in not assignable to number
const y: number = columns[i];
// @ts-expect-error: number | undefined in not assignable to number
const z: number = columns[0];
// @ts-expect-error: number[] | undefined in not assignable to number[]
const u: number[] = matrix[j];
}
}The assertArrayIndex function cannot be called on tuples (since the accessing the elements is already well typed in them):
const tuple = [5, 7] as const;
// @ts-expect-error
assertArrayIndex(tuple, 'tuple');(Additional design considerations for the proposed API: #925.)
View on GitHub: https://tsch.js.org/925
Change the following code to make the test cases pass (no type check errors).
/* _____________ Your Code Here _____________ */
function assertArrayIndex(array: readonly unknown[], key: string) {}
type Index<Array> = any
/* _____________ Test Cases _____________ */
const matrix = [
[3, 4],
[5, 6],
[7, 8],
]
assertArrayIndex(matrix, 'rows')
let sum = 0
for (let i = 0 as Index<typeof matrix>; i < matrix.length; i += 1) {
const columns: number[] = matrix[i]
// @ts-expect-error: number | undefined in not assignable to number
const x: number[] = matrix[0]
assertArrayIndex(columns, 'columns')
for (let j = 0 as Index<typeof columns>; j < columns.length;Unlock 150+ medium, hard, and extreme challenges to master advanced TypeScript.
Monthly subscription. Cancel anytime.
declare const arrayKey: unique symbol
type Code = {
// one two-digit code per letter
a: '10'
z: '35'
}
type Hash<
S extends string,
Acc extends string = '',
> = S extends `${infer Head}${infer Tail}`
? Hash<Tail, Head extends keyof Code ? `${Acc}${Code[Head]}` : Acc>
: `-${Acc}` extends `${infer N extends number}`
? N
: never
type Checked<A extends readonly unknown[], K extends string> = Omit<A, number> &
Record<Hash<K>, A[number]> & { readonly [arrayKey]: K }
function assertArrayIndex<A extends readonly unknown[], K extends string>(
array: A | Checked<A, K>,
key: K & (number extends A['length'] ? unknown : never),
): asserts array is Checked<A, K> {}
type Index<A> = A extends { readonly [arrayKey]: infer K extends string }
? Hash<K>
: numberAn array type carries [index: number]: T. Any expression of type number matches that signature, so matrix[0], matrix[i] and matrix[j] are the same lookup as far as the compiler is concerned. Branding the index value does not help, because index signatures match by assignability and a branded number is still a number.
The way out is to drop the index signature and put a single property in its place, named after one specific number. Property lookups are exact: matrix[-27243228] finds it, matrix[0] does not.
Hash<K> produces that number. Each letter maps to a fixed two-digit code, the codes are concatenated into a string, and the string is read back as a numeric literal:
For 'rows' that is r -> '27', o -> '24', w -> '32', s -> '28', giving '27243228' and finally -27243228.
The conversion step is `-${Acc}` extends `${infer N extends number}`. Placing extends number on an infer inside a template literal pattern hands you the numeric literal type instead of the string. The leading minus is not decoration: a real array index is never negative, so a negative property name can never collide with one.
Characters outside a to z are skipped, because Head extends keyof Code fails for them and Acc is passed through untouched. That makes this a hash and not an encoding, so keys differing only in punctuation collide. Telling apart the keys a program actually uses is all the assertion needs.
Checked<A, K> has three parts:
Omit<A, number> keeps every member of the array type except the number index signature. length, push and [Symbol.iterator] all survive, so a.push('qux') and for (const value of a) still work, and a readonly number[] still has no push.Record<Hash<K>, A[number]> is the one way back in: a property named -27243228 holding the element type.{ readonly [arrayKey]: K } records which key this array was tagged with, so Index<A> can find its way back to the same hash. When an array carries no tag, Index<A> falls back to number, which is what the array had all along.Worth noticing: the assertion replaces the type of matrix rather than intersecting with it. That happens only because Checked<A, K> is a subtype of A. It qualifies despite having no number index signature, since the hashed property has a numeric name and a type matching what that index signature promised. An intersection would have kept the original signature, and every unsafe read would still compile.
An assertion predicate has to be assignable to the type of the parameter it narrows, and Checked<A, K> is not assignable to a bare type parameter A. Writing array: A is rejected on the spot with "A type predicate's type must be assignable to its parameter's type". Widening the parameter to A | Checked<A, K> settles it at no cost: a plain array still matches the A half, and the predicate matches the other half by construction.
The tuple rejection then rides on the second parameter, key: K & (number extends A['length'] ? unknown : never). For number[] the length is number, the conditional resolves to unknown, and the intersection is just K. For a tuple such as [number] the length is 1, number extends 1 is false, and the parameter type collapses to never, so the call is rejected. Tuples index correctly on their own, so there is nothing to assert.
let i = 0 as Index<typeof matrix> gives i the literal type -27243228. TypeScript still accepts i += 1 on it, and inside the loop body i reads back as that literal, which is what the property lookup needs. The value is a lie at runtime, and that is fine: assertArrayIndex has an empty body, and none of this survives compilation.
matrix[0] and a[2]: literal indices are property lookups too, and neither 0 nor 2 is the hashed name, so both fail.columns[i] in the nested loop: i hashes 'rows' while columns was tagged 'columns', so the property is missing and the read errors. This is the bug the whole challenge exists for.for (let p = 0; p < c.length; p += 1): an untagged number counter matches nothing on a checked array, so c[p] fails.d: readonly number[]: Omit carries the readonly array's members over unchanged, so d.push(3) is still missing and d[2] = 3 has no property to write to.[5, 7] as const: length 2, so the key parameter is never and the call is turned away.This challenge is originally from here.
Track your progress through 100+ hands-on challenges. Free, sign in with GitHub.
Or start solving right away: explore all TypeScript challenges