Skip to content
Domain Specific Language

Array indexing is a footgun and ? is here to help you

A short look at why array indexing bugs happen, and ?uestioning why languages don't do more. I need you to use the nearest question mark.

useEffect(() => {
    if (activeAccount?.id) {
        cancelable.exec(
            "id",
            networking.search(
                param1,
                param2,
                undefined,
                undefined,
                undefined,
                "param3",
                param4,
                param5,
                "param6",
                param7,
            ),
        ).then((result) => {
            if (result.content.length > 0) {
                setShowCreateTopLevel(true);
            }
            if (result.content[0].levelId) {
                setTopLevelId(result.content[0].levelId);
            }
        });
    }
}, []);

Imagine reviewing this code, a normal and good thing to do, and finding nothing wrong. Imagine this being one of a handful of changes in different files. My money is you don't find the bug.

Personally, I think my brain would believe that the first bounds check result.content.length > 0 was a refinement, and that the code was working as it should. Workload goes up, review quality goes down.

But this bug is completely unnecessary, especially in modern JavaScript, TypeScript and Rust. Unfortunately, the compilers for TypeScript and Rust won't help you find the bug. But I'll come back to the bug in a minute.

I like Rust and I like TypeScript but in different ways and for different reasons.

I like Rust because of P̸̡̿a̸̮̋ṛ̷͝s̴̺̉͝e̸͕̖͆̕ ̴̪̐́Ḍ̶̹̒o̵̲̹͊̽n̷͕͂'̸̨̉t̸̖̚ ̵̕͜Ṿ̴̬͛a̴̤̍̕l̶͇͘i̴̻̐d̷̙̣̈a̶͕͛͛t̶̬̀͘ē̶̲̟, Nominal Types, and great error handling.

I like TypeScript because JavaScript is unusable and TypeScript is marginally less unusable.

But is it really unusable?

Yes it is extremely unusable.

JavaScript is unusable

Unusable is a strong word — Like most other scripting languages, JavaScript has the following properties:

TypeScript doesn't have those same properties. As long as you are diligent about your design, use types everywhere, and turn on all the strict checks the compiler has, you can keep adding people and have (slightly sub-)linear increases in productivity.

But TypeScript has so many paper cuts. I'm figuratively bleeding.

And TypeScript is unsound

TypeScript is wildly unsound, and it will let you do this:

// Function calls don't invalidate refinements
class UnsoundRefinement {
    private mutable?: string;

    refineMe(): void {
        this.mutable = "Not undefined";
        if (this.mutable !== undefined) {
            this.clearMutable();
            // This is a runtime Error
            console.log(this.mutable.length);
        }
    }

    clearMutable(): void {
        this.mutable = undefined;
    }
}

And TypeScript doesn't seem to care that it's unsound

Here's TypeScript just throwing its hands in the air:

strictFunctionTypes

During development of this feature, we discovered a large number of inherently unsafe class hierarchies, including some in the DOM. Because of this, the setting only applies to functions written in function syntax, not to those in method syntax.

The TypeScript Documentation

The reason I know about this is because I managed to run into it very early in my TypeScript "career".

Sound programming languages love runtime errors

I don't like runtime errors. I'll be clear and state that I'll take a runtime error (or panic) over unsoundness every day of the week, but I don't have to like it.

C programmers claim that they can allocate and deallocate memory manually and that it's "fine, actually" (it's not). Rust and TypeScript programmers claim that they can decide when to check array lengths before accessing elements and that "it's fine actually" (it is fine actually) — BUT I DON'T LIKE IT.

fn feeling_lucky(slice: &[&str]) -> usize {
    // Runtime panic if slice is shorter than 1000 elements.
    // The panic is on the index access, not the .len() call.
    slice[999].len()
}
const feelingLucky = (arr: string[]): number => {
    // Throws a runtime error if slice is shorter than 1000 elements.
    // The panic is on the .length access, since index-out-of bounds returns undefined.
    return arr[999].length;
}

There is no reason why the ? operator can't just solve this. Both of them. The type signatures will be different, but that's ok with me.

fn am_lucky_actually(slice: &[&str]) -> Option<usize> {
    // This isn't actually valid rust, can't use ? here:
    slice[999]?.map(|v| v.len())
    // This is what you would need to write today:
    // slice[999].get()?.map(|v| v.len())
}
const amLuckyActually = (arr: number[]): number | undefined => {
    return arr[999]?.length;
}

I personally think array indexing shouldn't lead to runtime crashes except when doing .unwrap(), !. In Rust it could be argued that you could skip the checking step in unsafe code, but get_unchecked already exists so big shrug, I guess.

Since TypeScript already has (broken) refinement, it could just allow infallible array indexing when it can prove that the access will be in bounds. Maybe Rust can get that too, some day. Refinement is really neat, and I wish it wasn't broken in TypeScript.

Back to the initial review

Load bearing code —

            if (result.content.length > 0) {
                setShowCreateTopLevel(true);
            }
            if (result.content[0].levelId) {
                setTopLevelId(result.content[0].levelId);
            }

The length check doesn't wrap the array indexing call.

I easily miss details like this, and I would expect the compiler to have my back. It does, in a sense, have my back (it crashes), but I resent that I have to find this error in Sentry. 2500 crashes in one week, just as everyone is enjoying their vacation.

It's not a big deal. It really isn't. But if I could choose, I think I'd like to have a compiler that didn't let me crash unexpectedly.