Pedantic index signatures in TypeScript 4.1
— TypeScript — 3 min read
- 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:
1type Widget = {2 id: string3 title: string4}5
6type WidgetIndex = Record<string, Widget>7
8// alternate syntax:9type WidgetIndex = {10 [id: string]: Widget11}
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:
1type Widget = {2 id: string3 title: string4}5
6const widgetIndex: Record<string, Widget> = {7 widget1: { id: 'widget1', title: 'Foo' },8 widget2: { id: 'widget2', title: 'Bar' },9}10
11widgetIndex['helloWorld'].title.toUpperCase()
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:
1type Widget = {2 id: string3 title: string4}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}
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:
1type SafeRecord<Key extends string | number | symbol, Value> = Record<2 Key,3 Value | undefined4>
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.
1const strings: Array<string> = ['one', 'two', 'three']2
3strings[999].toUpperCase()
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:
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:
1type NonEmptyArray<T> = [T, ...Array<T>]2const isNonEmpty = <T extends unknown>(3 array: Array<T>4): array is NonEmptyArray<T> => array.length > 05
6const strings: Array<string> = ['one', 'two', 'three']7
8const firstToUpper = (list: NonEmptyArray<string>) => list[0].toUpperCase()9
10// this will type-err11firstToUpper(strings)12
13// this is fine14if (isNonEmpty(strings)) {15 firstToUpper(strings)16}
--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. ⬇️