loke.dev
Header image for The TypeScript Index Is a Runtime Lie

The TypeScript Index Is a Runtime Lie

Why the default TypeScript array indexing is inherently unsafe and how the `noCheckedIndexedAccess` flag eliminates the most common source of production runtime errors.

· 3 min read

I spent my first year in TypeScript feeling invincible. I truly believed that if my code compiled, it simply couldn't crash. Then I wrote a line as simple as const firstUser = users[0]; and watched my production logs fill with TypeError: Cannot read property 'id' of undefined. I was confused—users was typed as User[], so users[0] should be a User, right? Wrong. TypeScript was lying to me, and it’s likely lying to you too.

The Great Type System Gaslight

By default, TypeScript is surprisingly optimistic. It assumes that if you have an array of strings, every index you access will magically contain a string. It ignores the very real possibility that the array might be empty or that you're reaching for an index that doesn't exist.

Check out this snippet. On the surface, it looks perfectly safe:

const names: string[] = [];

// TypeScript says 'firstName' is a 'string'
const firstName = names[0]; 

// This compiles perfectly, but crashes at runtime
console.log(firstName.toUpperCase()); 
// ❌ Uncaught TypeError: Cannot read properties of undefined

If you hover over firstName in your IDE, TypeScript will confidently tell you the type is string. This is a lie. The type is actually string | undefined.

Why does TypeScript do this? It’s a trade-off. In the early days, the team decided that forcing developers to check for undefined every single time they accessed an array would be too "noisy" and would make migrating from JavaScript a nightmare. They chose developer convenience over absolute safety.

Fixing the Leak with noCheckedIndexedAccess

In TypeScript 4.1, the team introduced a flag that finally lets us opt into the truth: noCheckedIndexedAccess.

You can flip this on in your tsconfig.json:

{
  "compilerOptions": {
    "noCheckedIndexedAccess": true
  }
}

Once this is enabled, the behavior of your code changes fundamentally. Now, when you access an element by index, TypeScript forces you to acknowledge the reality of the situation.

const names: string[] = ["Alice", "Bob"];

// With the flag enabled, 'nameAtIndex' is now 'string | undefined'
const nameAtIndex = names[2];

if (nameAtIndex) {
    console.log(nameAtIndex.toUpperCase()); // Safe!
} else {
    console.log("No name found at this index.");
}

The "Annoyance" of Being Correct

I’ll be honest: when you first turn this flag on in an existing codebase, you’re going to hate it for about twenty minutes. You’ll find yourself writing a lot more if statements or using the optional chaining operator (?.).

But here’s the thing—those "annoying" checks are exactly the logic that was missing from your app. You weren't safe before; you were just lucky.

Consider a common scenario: grabbing the first item of a filtered list.

interface Product {
    id: string;
    price: number;
}

function getCheapProduct(products: Product[]) {
    const cheapOnes = products.filter(p => p.price < 10);
    
    // Without the flag: 'first' is Product (even if list is empty)
    // With the flag: 'first' is Product | undefined
    const first = cheapOnes[0];

    return first?.id ?? "No cheap products available";
}

Where the Flag Gets a Little Grumpy

There is one specific place where noCheckedIndexedAccess feels a bit pedantic: the classic for loop.

const items = [1, 2, 3];

for (let i = 0; i < items.length; i++) {
    // Even though we know 'i' is within bounds, 
    // TypeScript still marks 'item' as potentially undefined.
    const item = items[i]; 
    
    if (item !== undefined) {
        console.log(item * 2);
    }
}

In this case, the compiler isn't smart enough to correlate the loop condition (i < items.length) with the index access. To get around this, I usually recommend moving toward more modern iteration methods that provide better type inference out of the box.

Using forEach, map, or for...of avoids the index issue entirely because TypeScript knows those values exist:

// 'item' is correctly inferred as 'number' here
for (const item of items) {
    console.log(item * 2);
}

Why You Should Probably Turn It On

Runtime errors are the most expensive kind of errors. They happen when your users are trying to use your product. They trigger Sentry alerts and pager duty calls.

By enabling noCheckedIndexedAccess, you are essentially telling the compiler: "Stop pretending JavaScript is safe." It turns a category of common runtime crashes into simple compile-time chores.

If you're starting a new project, turn it on immediately. If you're on a large existing project, try turning it on and just looking at the errors. You might find a dozen places where your app is currently held together by nothing but hope and luck.