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: string
framework = const frameworks: string[]
frameworks[5];
const framework: string
framework.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.push('SvelteKit');
// ❌ Reassigning is not allowed
frameworks = ['SvelteKit'];
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 | undefined
framework = const frameworks: string[]
frameworks[5];
framework.String.toUpperCase(): string
Converts all the alphabetic characters in a string to uppercase.toUpperCase();
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 | undefined
framework = const frameworks: string[]
frameworks[0];
const framework: string | undefined
framework?.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';
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: ArrayWithThreeElements
frameworks: type ArrayWithThreeElements = [string, string, string]
ArrayWithThreeElements = ['Nuxt', 'Remix', 'Ember'];
// ✅ No undefined check needed
const const framework: string
framework = const frameworks: ArrayWithThreeElements
frameworks[0];
const framework: string
framework.String.toUpperCase(): string
Converts all the alphabetic characters in a string to uppercase.toUpperCase();
// ✅ Mutating the existing elements is allowed
const frameworks: ArrayWithThreeElements
frameworks[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: ArrayWithAtLeastOneElement
frameworks: type ArrayWithAtLeastOneElement = [string, ...string[]]
ArrayWithAtLeastOneElement = ['Nuxt', 'Remix', 'Ember'];
// ✅ No undefined check needed
const const framework: string
framework = const frameworks: ArrayWithAtLeastOneElement
frameworks[0];
const framework: string
framework.String.toUpperCase(): string
Converts all the alphabetic characters in a string to uppercase.toUpperCase();
// ✅ Mutating the existing elements is allowed
const frameworks: ArrayWithAtLeastOneElement
frameworks[0] = 'SvelteKit';
// ✅ Adding extra elements is allowed
const frameworks: ArrayWithAtLeastOneElement
frameworks.Array<string>.push(...items: string[]): number
Appends new elements to the end of an array, and returns the new length of the array.push('Next');
// 🚨 Other elements are possibly undefined
const const anotherFramework: string | undefined
anotherFramework = const frameworks: ArrayWithAtLeastOneElement
frameworks[3];
anotherFramework.String.toUpperCase(): string
Converts all the alphabetic characters in a string to uppercase.toUpperCase();
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 | undefined
framework = const frameworks: string[]
frameworks[0];
if (const framework: string | undefined
framework !== var undefined
undefined) {
const framework: string
framework.String.toUpperCase(): string
Converts all the alphabetic characters in a string to uppercase.toUpperCase();
} else {
// Handle missing data
var console: Console
console.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.