All posts

Avoiding runtime errors with array indexing in TypeScript

Ignace Maes
Ignace Maes Apr 09, 2024

There are a couple of ways in which the default TypeScript config allows unsafe operations. One of these is related to accessing array elements.

In the following example, an array of strings is defined and the the non-existing element at index five is accessed. This will result in the value being undefined, which leads to a runtime error when calling toUpperCase on it.

const const frameworks: string[]frameworks = ['Nuxt', 'Remix', 'Ember'];

// 💥 Will crash at runtime
const const framework: stringframework = const frameworks: string[]frameworks[5];
const framework: stringframework.String.toUpperCase(): string
Converts all the alphabetic characters in a string to uppercase.
toUpperCase
();

But why doesn't TypeScript notify about this error at compile time? 🤔

Const is a lie (sortof)

TypeScript allows accessing any index of the array by default. Even if it's out of bounds, it will still return the type of the array element, in this case string.

The underlying reason for this odd behavior is that const keyword in JavaScript might not mean what you think it means. In JavaScript, const only means that the variable cannot be reassigned. It does not mean that the value is immutable.

const const frameworks: string[]frameworks = ['Nuxt', 'Remix', 'Ember'];

// ✅ Mutating the array is allowed
const frameworks: string[]frameworks.Array<string>.push(...items: string[]): number
Appends new elements to the end of an array, and returns the new length of the array.
@paramitems New elements to add to the array.
push
('SvelteKit');
// ❌ Reassigning is not allowed frameworks = ['SvelteKit'];
Cannot assign to 'frameworks' because it is a constant.

So accessing element with index five in the initial example could have been a valid operation, if extra elements were added to the array up front.

Compiler flags to the rescue

Writing code that might work at runtime is not great. The sooner you can get feedback warning code is prone to errors, the better.

For this purpose, TypeScript offers multiple compiler flags which help you write safer code. One of these flags is noUncheckedIndexedAccess. This flag helps you catch undefined values when accessing array elements.

To enable the this flag, add the following setting to your tsconfig.json:

{
  "compilerOptions": {
    "noUncheckedIndexedAccess": true, 
    // other flags ...
  }
}

Now TypeScript will infer array elements as potentially being undefined. An error will be throw when trying to do any operations without checking on this first.

const const frameworks: string[]frameworks = ['Nuxt', 'Remix', 'Ember'];

// ✅ TypeScript now catches the error
const const framework: string | undefinedframework = const frameworks: string[]frameworks[5];
framework.String.toUpperCase(): string
Converts all the alphabetic characters in a string to uppercase.
toUpperCase
();
'framework' is possibly 'undefined'.

Handling stricness

Having this extra safety at compile time is great. But for the same reason that elements might have been added to the array, they might also have been removed. Accessing an element at the first index now requires checking on undefined too.

const const frameworks: string[]frameworks = ['Nuxt', 'Remix', 'Ember'];

// 🚨 Check on undefined
const const framework: string | undefinedframework = const frameworks: string[]frameworks[0];
const framework: string | undefinedframework?.String.toUpperCase(): string
Converts all the alphabetic characters in a string to uppercase.
toUpperCase
();

Having to do these extra checks is a bit cumbersome if we know the first element will always be present. How do we inform TypeScript about this?

1. Using "as const"

One way to mitigate this is to use the as const assertion. This tells TypeScript that the array will never change, and that it should infer the types as literals.

const const frameworks: readonly ["Nuxt", "Remix", "Ember"]frameworks = ['Nuxt', 'Remix', 'Ember'] as type const = readonly ["Nuxt", "Remix", "Ember"]const;

// ✅ No undefined check required
const const framework: "Nuxt"framework = const frameworks: readonly ["Nuxt", "Remix", "Ember"]frameworks[0];
const framework: "Nuxt"framework.String.toUpperCase(): string
Converts all the alphabetic characters in a string to uppercase.
toUpperCase
();
// ❌ Mutating the array is no longer allowed const frameworks: readonly ["Nuxt", "Remix", "Ember"]frameworks[0] = 'SvelteKit';
Cannot assign to '0' because it is a read-only property.

This is a great solution if all elements are known upfront. The ability to do mutations, however, is lost.

2. Using tuples

Another way to handle this is to create a custom type for the array. This way you can define the exact types of the elements and the length of the array. In TypeScript, this specific version of an array type is called a tuple.

type type ArrayWithThreeElements = [string, string, string]ArrayWithThreeElements = [string, string, string];
const const frameworks: ArrayWithThreeElementsframeworks: type ArrayWithThreeElements = [string, string, string]ArrayWithThreeElements = ['Nuxt', 'Remix', 'Ember'];

// ✅ No undefined check needed
const const framework: stringframework = const frameworks: ArrayWithThreeElementsframeworks[0];
const framework: stringframework.String.toUpperCase(): string
Converts all the alphabetic characters in a string to uppercase.
toUpperCase
();
// ✅ Mutating the existing elements is allowed const frameworks: ArrayWithThreeElementsframeworks[0] = 'SvelteKit';

If more flexibility is needed, a powerful trick is to use the rest operator to allow for extra elements. This way the exact types of some elements can be defined, while allowing for more elements.

type type ArrayWithAtLeastOneElement = [string, ...string[]]ArrayWithAtLeastOneElement = [string, ...string[]];
const const frameworks: ArrayWithAtLeastOneElementframeworks: type ArrayWithAtLeastOneElement = [string, ...string[]]ArrayWithAtLeastOneElement = ['Nuxt', 'Remix', 'Ember'];

// ✅ No undefined check needed
const const framework: stringframework = const frameworks: ArrayWithAtLeastOneElementframeworks[0];
const framework: stringframework.String.toUpperCase(): string
Converts all the alphabetic characters in a string to uppercase.
toUpperCase
();
// ✅ Mutating the existing elements is allowed const frameworks: ArrayWithAtLeastOneElementframeworks[0] = 'SvelteKit'; // ✅ Adding extra elements is allowed const frameworks: ArrayWithAtLeastOneElementframeworks.Array<string>.push(...items: string[]): number
Appends new elements to the end of an array, and returns the new length of the array.
@paramitems New elements to add to the array.
push
('Next');
// 🚨 Other elements are possibly undefined const const anotherFramework: string | undefinedanotherFramework = const frameworks: ArrayWithAtLeastOneElementframeworks[3]; anotherFramework.String.toUpperCase(): string
Converts all the alphabetic characters in a string to uppercase.
toUpperCase
();
'anotherFramework' is possibly 'undefined'.

3. Actually checking on undefined

There are cases where you do intentionally want to check on undefined. An example could be where data is fetched from an API and you want to check if the data is present.

const const frameworks: string[]frameworks = function fetchFromApi(): string[]fetchFromApi();

const const framework: string | undefinedframework = const frameworks: string[]frameworks[0];
if (const framework: string | undefinedframework !== var undefinedundefined) {
  const framework: stringframework.String.toUpperCase(): string
Converts all the alphabetic characters in a string to uppercase.
toUpperCase
();
} else { // Handle missing data var console: Consoleconsole.Console.error(...data: any[]): void
[MDN Reference](https://developer.mozilla.org/docs/Web/API/console/error)
error
('No frameworks returned by API');
}

This is perfectly valid, and the noUncheckedIndexedAccess flag will be a welcome reminder to properly handle these error cases at runtime.

Conclusion

TypeScript allows you to write unsafe code by default. Making array indexing safer can be done by enabling the noUncheckedIndexedAccess flag. This will make TypeScript infer array elements as potentially undefined, which will help catch errors at compile time.

To handle these stricter types, you can use const assertions or define tuple types. This will help you write safer code and catch errors earlier in the development process.

Interested in content like this? Follow along on Twitter X.

Ignace Maes
Ignace Maes

A software engineer from Belgium with a strong interest in technology. I love creating digital products that make people's life more enjoyable.