Everyday TypeScript: ReadonlyArray
Welcome to the ReadonlyArray lesson!
This lesson is shown as static text below. However, it's designed to be used interactively. Click the button below to start!
Our TypeScript code will inevitably modify values, for example by calling
someArray.push(...)or reassigning object properties likesomeObject.someProperty = someValue. The general term for this is "mutation". We mutate arrays by changing their elements. We mutate objects by changing their property values.Mutation is a notorious source of bugs. For example, we might call a function because we need its return value, not realizing that the function also mutates the arguments that we pass in.
There are some examples of this problem built in to JavaScript itself. We might expect
someArray.sort()to return a sorted version of the array, and it does.>
const originalLetters = ['c', 'a', 'b'];const sortedLetters = originalLetters.sort();sortedLetters;Result:
['a', 'b', 'c']
However,
.sort()also mutates the original array's contents, reordering its elements!>
const originalLetters = ['c', 'a', 'b'];const sortedLetters = originalLetters.sort();originalLetters;Result:
['a', 'b', 'c']
This can lead to subtle, difficult bugs. Here's a breakdown of one such situation:
- We have an array called
emails. It's not in any particular order. - Some code loops through
emails, sending an email to each address. Sending each email is an asynchronous operation that takes a bit of time. During that time, other code in the system can run. - Some other code happens to run while the emails are sending.
It calls
emails.sort(). The programmer who wrote that call didn't realize that.sort()actually changes the array's contents, rather than simply returning a new array.
- We have an array called
The question is: how does the sort call affect the email sending code? Rather than just write about it, let's simulate the whole scenario in code!
The next example is unusually long. We've added comments to explain the pieces, and we recommend reading through it carefully. It calls
console.logseveral times. When you run the example, you'll see the console output appear below the code in real time. There's no need to open your browser's development console!Depending on your JavaScript background, some of the asynchronous details here may be unfamiliar:
new Promise,setTimeout, etc. It's OK to gloss over those details as long as you understand the summaries in the comments. If you want to understand this example in full, our JavaScript Concurrency course covers everything you'll need (and more).>
async function sleep(milliseconds: number) {/* This is a common trick for creating a promise that waits for a* certain amount of time. We'll use this function to simulate delays,* like the delay when sending each email, and the delay before sorting* the email list. */await new Promise(resolve => setTimeout(resolve, milliseconds));}/* We'll send email to these addresses. Notice that they happen to be* sorted backwards, from "d" to "a". */const emails = ['dalili@example.com','cindy@example.com','betty@example.com','amir@example.com',];/* This function simulates emailing each email address. It waits for 500* ms each time, simulating the time that our email provider takes to* actually send the email. */async function sendEmails() {for (let i=0; i<emails.length; i++) {console.log(`Emailing ${emails[i]}`);await sleep(500);}}/* This function waits for 1.25 seconds, then sorts the email list. It* simulates some other code in the system working with the `emails`* array. We've selected 1.25 seconds because that timing interferes with* `sendEmails` in a predictable way. In real systems, this type of bug* is unpredictable and seems to occur at random. */async function sortEmailsAfterDelay() {await sleep(1250);console.log('Sorting email addresses now');emails.sort();}/* Now we start both asynchronous functions. They'll execute* concurrently, allowing them to interfere with each other. Most* real-world JavaScript code is highly concurrent. */sendEmails();sortEmailsAfterDelay();async console outputLook closely at the output from that code example. We wanted to loop over the email list, emailing each user. We used a
forloop, a very basic JavaScript control construct. And yet we emailed Dalili twice, and we never emailed Amir.The email array is sorted after 1.25 seconds, just before we send an email to
emails[3]. Before sorting,emails[3]is Amir. But after sorting,emails[3]is Dalili. ThesendEmailsfunction has no way to know that that happened. It continues looping, sending an email toemails[3], which used to be Amir but is now Dalili. Dalili gets two emails and Amir gets none.The nefarious part of this bug is that
sendEmailslooks fine on its own, andsortEmailsAfterDelayalso looks fine. If either of these functions is run by itself, there's no problem. Our bug only happens when the two functions happen to run concurrently.There are many ways to mitigate this problem. We can learn to avoid this scenario through sheer discipline. We can design systems that never share data between concurrent tasks like
sendEmailsandsortEmailsAfterDelay. Both of those are good approaches! But the more solutions we have, the better.Fortunately, TypeScript gives us a new tool to control mutation: read-only data. The rest of this lesson covers the array version of read-only data.
ReadonlyArray<T>is a generic type that acts just likeArray<T>, except thatReadonlyArray<T>doesn't have any methods that modify the underlying array. We can still access.length, call.indexOf(...), etc. But we can't call.sort(),.push(...), or other mutating methods. Attempting to call any mutating method is a compile-time type error.>
const numbers: ReadonlyArray<number> = [1, 2, 3];numbers.length;Result:
3
>
const numbers: ReadonlyArray<number> = [1, 2, 3];numbers.sort();Result:
type error: Property 'sort' does not exist on type 'readonly number[]'.
>
const strings: ReadonlyArray<string> = ['a', 'b', 'c'];strings.indexOf('b');Result:
1
>
const strings: ReadonlyArray<string> = ['a', 'b', 'c'];strings.push('d');Result:
type error: Property 'push' does not exist on type 'readonly string[]'.
Take a look at that last error message. We declared our type as
ReadonlyArray<string>, but the error message reported it asreadonly string[]. These are two different ways of writing the same type, just asArray<string>andstring[]are the same type.We can use
ReadonlyArrayto prevent our email sorting bug. If ouremailsarray is aReadonlyArray, rather than a regularArray, then callingemails.sort()is a type error. Our mistake is caught at compile time!>
const emails: ReadonlyArray<string> = ['dalili@example.com','cindy@example.com','betty@example.com','amir@example.com',];async function sortEmails() {console.log('Sorting email addresses now');return emails.sort();}Result:
type error: Property 'sort' does not exist on type 'readonly string[]'.
This is a great solution, as long as we remember to actually use
ReadonlyArray. For that reason, it's a good idea to useReadonlyArrayby default, unless your function's purpose is to mutate the array. If you later find that you do need to mutate it, you can always change the array's type.What if we actually do want to sort a read-only array? It's easy to do! We can copy the array with
.slice(). That gives us a new array with the regularArray<T>type. We can sort that array without modifying the original read-only array.>
const originalLetters: ReadonlyArray<string> = ['b', 'c', 'a'];const slicedLetters = originalLetters.slice();const sortedLetters = slicedLetters.sort();sortedLetters;Result:
['a', 'b', 'c']
>
const originalLetters: ReadonlyArray<string> = ['b', 'c', 'a'];const slicedLetters = originalLetters.slice();const sortedLetters = slicedLetters.sort();originalLetters;Result:
['b', 'c', 'a']
We can safely use
Arrays asReadonlyArrays. In English: "if an array allows mutation, then using it in a situation where it isn't mutated is also fine.">
const names: Array<string> = ['Amir', 'Betty'];const readonlyNames: ReadonlyArray<string> = names;readonlyNames[0];Result:
'Amir'
Here's a code problem:
The
appendfunction below adds a string to the end of an array of strings, returning a new array with that string added. It takes anArray<string>, which is a problem: it means that we can't pass read-only arrays to this function. That prevents us from using the function in some perfectly reasonable situations.Modify the
appendfunction's parameter type to take a read-only array instead. It will still be able to take regular, read-write arrays as well, because read-write arrays can always be used as read-only arrays. (You won't need to change the function's body.)function append(array: ReadonlyArray<string>, element: string) {return [...array, element];}const array1: Array<string> = ['a', 'b'];const array2: ReadonlyArray<string> = ['A', 'B'];[append(array1, 'c'),append(array2, 'C'),];- Goal:
[['a', 'b', 'c'], ['A', 'B', 'C']]
- Yours:
[['a', 'b', 'c'], ['A', 'B', 'C']]
In one of the examples above, we had an array in a variable with the type
Array<string>, and also in a different variable with the typeReadonlyArray<string>. Those two variables referenced the same underlying array.Because they're the same array, modifying one variable will also affect what we see in the other variable. For example:
>
const numbers: Array<number> = [1, 2, 3];const readonlyNumbers: ReadonlyArray<number> = numbers;const initialValue = readonlyNumbers[0];numbers[0] = 100;const finalValue = readonlyNumbers[0];[initialValue, finalValue];Result:
[1, 100]
The value at
readonlyNumbers[0]changed, even though it's a read-only array. But doesn't this violate the idea that it's read-only?It might seem surprising at first, but no, this isn't a violation of the read-only array. The
ReadonlyArraytype prevents us from modifying that read-only array variable directly, but other code can still modify the underlying array. This doesn't affect the usefulness ofReadonlyArray. When we write a function that takes aReadonlyArray<T>, it means "calling this function won't change the array, even though other unrelated code may change the array." That's good to know; it removes one piece of uncertainty about what will happen when we call the function.That's all there is to
ReadonlyArray. It's a simple type, but we spent a lot of time setting it up because it can seem unimportant at first glance. Hopefully, seeing an actual mutation bug shows that it can make a huge difference!TypeScript also has similar
ReadonlySetandReadonlyMaptypes. They work like theSetandMaptypes, except they don't allow any method calls that would mutate the set's or map's contents. (You may not have seen theMaptype yet.)In a later lesson, we'll see other ways to make data read-only, most notably in objects.