Skip to content
TkDodo's blog
BlueskyGithub

Pedantic index signatures in TypeScript 4.1

TypeScript3 min read

books
    No translations available.
  • Add translation

TypeScript 4.1 beta was announced recently, 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 (people have built a JSON parser on type level with this already 🤯) or recursive conditional types, I'm pretty sure I will be using pedantic index signature checks 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
1type Widget = {
2 id: string
3 title: string
4}
5
6type WidgetIndex = Record<string, Widget>
7
8// alternate syntax:
9type WidgetIndex = {
10 [id: string]: Widget
11}

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
1type Widget = {
2 id: string
3 title: string
4}
5
6const widgetIndex: Record<string, Widget> = {
7 widget1: { id: 'widget1', title: 'Foo' },
8 widget2: { id: 'widget2', title: 'Bar' },
9}
10
11widgetIndex['helloWorld'].title.toUpperCase()

TypeScript playground

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
1type Widget = {
2 id: string
3 title: string
4}
5
6const widgetIndex: Record<string, Widget> = {
7 widget1: { id: 'widget1', title: 'Foo' },
8 widget2: { id: 'widget2', title: 'Bar' },
9}
10
11const title: string = widgetIndex['helloWorld']?.title ?? {
12 thisIs: 'completely Untyped',
13}

TypeScript playground

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
1type SafeRecord<Key extends string | number | symbol, Value> = Record<
2 Key,
3 Value | undefined
4>

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
1const strings: Array<string> = ['one', 'two', 'three']
2
3strings[999].toUpperCase()

TypeScript playground

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.

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 or Scala. 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, which let us tackle that specific problem:

non-empty-array
1type NonEmptyArray<T> = [T, ...Array<T>]
2const isNonEmpty = <T extends unknown>(
3 array: Array<T>
4): 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 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
1type NonEmptyArray<T> = [T, ...Array<T>]
2const isNonEmpty = <T extends unknown>(
3 array: Array<T>
4): array is NonEmptyArray<T> => array.length > 0
5
6const strings: Array<string> = ['one', 'two', 'three']
7
8const firstToUpper = (list: NonEmptyArray<string>) => list[0].toUpperCase()
9
10// this will type-err
11firstToUpper(strings)
12
13// this is fine
14if (isNonEmpty(strings)) {
15 firstToUpper(strings)
16}

TypeScript playground

--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 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 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. ⬇️