Decoding tsconfig options - noUncheckedIndexedAccess
The noUncheckedIndexedAccess
option in TypeScript is a useful feature that helps to write safer code. But it is not enabled by default and unfortunately stays disabled even when using Typescript's "strict" configuration. This, sadly, often results in the option being overlooked and unused.
So, what is noUncheckedIndexedAccess
, and in how does it affect type-checking?
Example with objectsβ
Let's consider this small example where noUncheckedIndexedAccess
is disabled and we try to group users by role, by looping over the users array and adding them to an userByRole
object:
type User = {
name: string;
role: string;
};
const groupUsersByRole = (users: Array<User>) => {
let userByRole: Record<string, Array<User>> = {};
users.forEach((user) => {
userByRole[user.role].push(user);
});
return userByRole;
};
const users: Array<User> = [
{ name: "John", role: "Admin" },
{ name: "Martin", role: "Moderator" },
];
const usersByRole = groupUsersByRole(users);
This might not be obvious at first because Typescript seems very happy with it all, but this code will blow up at runtime π±
let userByRole: Record<string, Array<User>> = {};
users.forEach((user) => {
userByRole[user.role].push(user);
// π₯ Cannot read properties of undefined (reading 'push')
});
We completely forgot to check if userByRole[user.role]
existed before pushing the user into the array, which caused this error.
But why didn't Typescript warn of of this?
Well, the reason is that we typed userByRole
as Record<string, Array<User>>
. By default, TypeScript will interpret this as "the keys of this record will always be strings, and the values will always be Array<User>
".
You can verify this by attempting to retrieve the value from a random key in our object:
const userByRole: Record<string, Array<User>> = {};
const randomValue = userByRole["random-key"];
// ^^^^^^^^^^^^^^
// ποΈ type of randomValue is "User[]"
Yep, you see this right. Typescript is trying to convince us that randomValue
is an array, even though we can clearly see we haven't set any values for this key when defining the userByRole
object, so in practice, it is actually undefined
.
Getting Typescript errors for our broken codeβ
This is where noUncheckedIndexedAccess
comes to the rescue. The official Typescript documentation states that it "will add undefined
Β to any un-declared field in the type". In other words, if Typescript isn't sure that a field exists in an object, it will add undefined
to this object field type and force us to verify whether it exists before using it.
So with noUncheckedIndexedAccess
enabled, we would have gotten the following warning ahead of time:
let userByRole: Record<string, Array<User>> = {};
users.forEach((user) => {
userByRole[user.role].push(user);
// ^^^^^^^^^^^^^^^^^^^^^^
// β Object is possibly 'undefined'.
});
How to fix the broken codeβ
So, how do we fix this?
Solution 1, still broken, from a type perspectiveβ
At first, we could try the following code, which will fix the issue from a runtime perspective:
users.forEach((user) => {
if (usersByRole[user.role]) {
usersByRole[user.role].push(user);
// ^^^^^^^^^^^^^^^^^^^
// β Object is possibly 'undefined'.
} else {
usersByRole[user.role] = [user];
}
});
users.forEach((user) => {
if (usersByRole[user.role]) {
usersByRole[user.role].push(user);
// ^^^^^^^^^^^^^^^^^^^
// β Object is possibly 'undefined'.
} else {
usersByRole[user.role] = [user];
}
});
Unfortunately, Typescript isn't clever enough to "remember" that we just checked if usersByRole[user.role]
existed before pushing the user into the array.
Solution 2, using non-null assertionsβ
If you do not mind using non-null assertions (!),Β you can use one to tell the compiler that we know better than it does β usersByRole[user.role]
is definitely not null or undefined since we just checked it:
users.forEach((user) => {
if (usersByRole[user.role]) {
/*
* We use ! to tell the compiler that we know better than it does.
* usersByRole[user.role] cannot be undefined
* since we just checked for that in the `if` statement above
*/
usersByRole[user.role]!.push(user);
} else {
usersByRole[user.role] = [user];
}
});
Solution 3, the functional approachβ
If you are like me and you find using non-null assertions a bit of a slippery slope and would rather avoid them, we can also use a slightly more functional approach to solve this issue without using non-null assertions.
While I personally rarely run into cases where this matters, beware that this is not very performant on large lists of users since it's recreating an array for every user in the list:
users.forEach((user) => {
const otherUsers = usersByRole[user.role];
// Beware this code's performance when working with big lists of users
usersByRole[user.role] = otherUsers ? [...otherUsers, user] : [user];
});
Solution 4, tightening the role typeβ
While this is a bit outside of the scope of this article, I couldn't in good conscience fail to mention what I believe is the best way to type this code. Even without noUncheckedIndexedAccess
, this code would have warned us something was wrong when type checking rather than at runtime:
Since users are unlikely to be assigned any random string as their user role, we can explicitly type those as a union of the potential values:
type UserRole = "Moderator" | "Admin" | "User";
type User = {
name: string;
role: UserRole;
};
const groupUsersByRole = (users: Array<User>) => {
/**
* We are forced to type the object as "Partial" here,
* as otherwise Typescript would complain that:
*
* Type '{}' is missing the following properties
* from type 'Record<UserRole, User[]>': Moderator, Admin, User
*
* By making the Type "Partial", Typescript will know that
* we are dealing with an object whose properties might be undefined,
* regardless of whether `noUncheckedIndexedAccess` is enabled or not
*/
let usersByRole: Partial<Record<UserRole, Array<User>>> = {};
users.forEach((user) => {
const otherUsers = usersByRole[user.role];
// Beware this code's performance when working with big lists of users
usersByRole[user.role] = otherUsers ? [...otherUsers, user] : [user];
});
return usersByRole;
};
const users: Array<User> = [
{ name: "John", role: "Admin" },
{ name: "Martin", role: "Moderator" },
{ name: "Louis", role: "Moderator" },
];
const usersByRole = groupUsersByRole(users);
Behavior with arraysβ
noUncheckedIndexedAccess
also affects arrays and will warn us about trying to access un-declared elements in an array:
const fruits = ["banana", "apple"];
console.log(fruits[0].toUpperCase());
// ^^^^^^^^^^
// β'Object is possibly 'undefined'
But wait, you say, I know that fruits[0]
is defined; we're literally setting a value for it when declaring our array.
To understand why this is the case, we need to look closer at how Typescript is inferring the types of our array. In this case, we can see that it infers them as string[],
in other words, an array of unknown length with any strings in it. So, from Typescript's perspective, there is no guarantee that any element will be present in the array, which is why it warns us against using fruits[0]
directly.
To fix this, we could type the array as const
, which will make Typescript infer a more exact type for the array:
const fruits = ["banana", "apple"] as const;
// ^^^^^^
// ποΈ type of fruits is `readonly ["banana", "apple"]`
console.log(fruits[0].toUpperCase());
// ^^^^^^^^^
// ποΈ type of fruits[0] is "banana"
// Typescript is now satisfied that this code will work without issues
Looping over arraysβ
There are two noteworthy points to mention when looping over arrays:
-
Inference limitations in
for
loops: while we might be able to reason that based on the loop's logic, we are only accessing properties within the bounds of our array, Typescript does not. So, it will addundefined
to the type of the properties accessed within the array:const fruits = ["banana", "apple"] as const;
for (let i = 0; i < fruits.length; i++) {
const fruit = fruits[i];
// ^^^^^
// ποΈ type of fruit is "banana" | "apple" | undefined
/*
* We have to check whether fruit is defined
* before being able to call toUpperCase on it
*/
if (fruit) {
console.log(fruit.toUpperCase());
}
} -
noUncheckedIndexedAccess
assumes "sparse arrays" are not a thing. Sparse arrays are arrays whose elements might not be contiguous to each other. This means some locations in the array might be empty:example of a sparse arrayconst fruits = ["banana", "apple"];
fruits[4] = "mango";
console.log(fruits); // prints: ['banana', 'apple', empty Γ 2, 'mango']While it was very easy to create such an array for the sake of our example, they are not the norm in most applications. It would require a lot more work for Typescript to handle them, so it was decided not to consider them when implementing the types for loops1. This means
for of
andforEach
loops will always assume that the elements we are looping over are defined, even though this might not always be true in practice.As a side note, it's worth pointing out that Javascript doesn't have a very consistent behavior when it comes to loops, so we should take this into account when deciding which style of loop to use depending on the application we are writing, whether we expect to handle sparse array, and how we want those sparse arrays to behave:
for loopconst fruits = ["banana", "apple"];
fruits[4] = "mango";
for (let i = 0; i < fruits.length; i++) {
const fruit = fruits[i];
// ^^^^^
// ποΈ type of fruit is "string" | undefined
// this matches the printed output
console.log(fruit);
}
// prints: "banana", "apple", undefined, undefined, "mango"for of loopconst fruits = ["banana", "apple"];
fruits[4] = "mango";
for (const fruit of fruits) {
// ^^^^^
// ποΈ type of fruit is "string"
// this is not true in practice, it doesn't match the printed output
console.log(fruit);
}
// prints: "banana", "apple", undefined, undefined, "mango"forEachconst fruits = ["banana", "apple"];
fruits[4] = "mango";
fruits.forEach((fruit) => {
// ^^^^^
// ποΈ type of fruit is "string"
// this matches the printed output
console.log(fruit);
});
// prints: "banana", "apple", "mango"
Conclusionβ
I would recommend that everyone use noUncheckedIndexedAccess,
as it is a helpful feature that helps write safer code. Not only does it help prevent runtime bugs, but some of the issues it reports can point to weaknesses in our type system, which can subsequently be improved. Lastly, we need to remember that JavaScript is a weird and very dynamic language; there's only so much that Typescript can do to fix that.
Footnotesβ
-
See the paragraph on "Design Point: Sparse Arrays" in the PR implementing the
noUncheckedIndexedAccess
option. β©