Skip to content
TkDodo's blog

Pedantic index signatures in TypeScript 4.1

Sep 20, 2020 — TypeScript
Pedantic index signatures in TypeScript 4.1
Photo by Sincerely Media

TypeScript 4.1 beta was announced recently (opens in a new window), and it introduces many new and exciting features. While I won’t pretend to understand (yet) what you will be able to do with template literal types (opens in a new window) (people have built a JSON parser (opens in a new window) on type level with this already 🤯) or recursive conditional types (opens in a new window), I’m pretty sure I will be using pedantic index signature checks (opens in a new window) whenever I can.

What is wrong with index signatures

If you add an index signature to an object, you tell TypeScript that you don’t know exactly which keys will be in that object, you only know which type the key will have. This is very useful if you build objects where you really don’t know the keys, for example, normalized objects where the keys are ids of database records:

index-signature
type Widget = {
id: string
title: string
}
type WidgetIndex = Record<string, Widget>
// alternate syntax:
type WidgetIndex = {
[id: string]: Widget
}

So naturally, when you try to retrieve a Widget from that index via it’s id, the access should yield Widget | undefined. But alas, it does not:

unsafe-access
type Widget = {
id: string
title: string
}
const widgetIndex: Record<string, Widget> = {
widget1: { id: 'widget1', title: 'Foo' },
widget2: { id: 'widget2', title: 'Bar' },
}
widgetIndex['helloWorld'].title.toUpperCase()

TypeScript playground (opens in a new window)

Yes, TypeScript is perfectly fine with that code, even though this will clearly err at runtime. This is not what I expect from a static type checker that aims at giving you safety at runtime, and I was really surprised when I found out that TypeScript behaves like that (and dare I say: a bit disappointed as well).

And it gets worse. Even if you, as a responsible developer, anticipate that the index access might give you undefined at runtime, and thus implement a fallback, TypeScript will completely ignore it and not type check it at all, because on type level, the index access will always return a value:

unsafe-fallback
type Widget = {
id: string
title: string
}
const widgetIndex: Record<string, Widget> = {
widget1: { id: 'widget1', title: 'Foo' },
widget2: { id: 'widget2', title: 'Bar' },
}
const title: string = widgetIndex['helloWorld']?.title ?? {
thisIs: 'completely Untyped',
}

TypeScript playground (opens in a new window)

SafeRecord to the rescue

Like many others, I believe this to be a big flaw of the language itself. But luckily, you can always implement your own types and use them instead of Record:

safe-record
type SafeRecord<Key extends string | number | symbol, Value> = Record<
Key,
Value | undefined
>

With that type, all values will also include undefined, so whenever you retrieve a value from a SafeRecord, you have to check for undefined first. This is a fine solution to the problem, but it has some other drawbacks. For example, calling Object.values on a SafeRecord will now give you an Array<T | undefined>, even though it’s really impossible for the values to actually be undefined. I still advocate for using SafeRecords whenever an index access is involved.

What about Arrays?

Well, Arrays are just as affected. After all, Arrays are (mostly) just objects where the index is a number.

unsafe-arrays
const strings: Array<string> = ['one', 'two', 'three']
strings[999].toUpperCase()

TypeScript playground (opens in a new window)

Oh boy, we should just go back to plain ES6 :(

At least, we are not going to directly access index 999 in Arrays that often. In practice, I have found that when an Array has a variable length, we often want to access the first element of that Array. If I find myself needing to access [1] or [2] a lot, I can usually use Tuples (opens in a new window).

So how can we fix accessing strings[0]? Yes, we can do a manual length check upfront, but no one will force us to. Having to think about this every single time is quite brittle and error prone, so can we enforce it on type level?

NonEmptyList

The concept of a non empty list exists in many programming languages, e.g. Haskell (opens in a new window) or Scala (opens in a new window). It basically means: A list with a length of at least one, so accessing the first element of that list will never fail.

TypeScript 4.0 introduced variadic tuple types (opens in a new window), which let us tackle that specific problem:

non-empty-array
type NonEmptyArray<T> = [T, ...Array<T>]
const isNonEmpty = <T extends unknown>(
array: Array<T>
): array is NonEmptyArray<T> => array.length > 0

This basically gives us a variadic tuple type where the first element is always defined, and a user-defined type guard (opens in a new window) that assures an Array is really non-empty. So if you want to force people to give you a non-empty Array so that you can safely access the first value, just type your function with a NonEmptyArray:

forced-empty-check
type NonEmptyArray<T> = [T, ...Array<T>]
const isNonEmpty = <T extends unknown>(
array: Array<T>
): array is NonEmptyArray<T> => array.length > 0
const strings: Array<string> = ['one', 'two', 'three']
const firstToUpper = (list: NonEmptyArray<string>) => list[0].toUpperCase()
// this will type-err
firstToUpper(strings)
// this is fine
if (isNonEmpty(strings)) {
firstToUpper(strings)
}

TypeScript playground (opens in a new window)

—noUncheckedIndexedAccess

What does all of this have to do with TypeScript 4.1 you might ask? Well, as this has been a feature request of many developers over the course of the years, but the change is also quite breaking for many existing apps, the TypeScript team has decided to introduce a new compiler flag with version 4.1. The flag is called noUncheckedIndexedAccess (opens in a new window) and it is going to solve the problem for Objects and Arrays alike by making sure that an index access will always return a possibly undefined value. Mapping over an Array or using Object.values will still be safe, so this doesn’t suffer from the same drawbacks as the SafeRecord type.

Here is a link (opens in a new window) to the latest TypeScript playground beta with that flag enabled so you can play around if you want.

It will still take about two months until that version is released, but I am really looking forward to it. 🎉


Let me know how you like the new TypeScript beta in the comments below. ⬇️

Like the monospace font in the code blocks?

Check out monolisa.dev

Bytes - the JavaScript Newsletter that doesn't suck