Execute Program

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 like someObject.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']Pass Icon
  • 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']Pass Icon
  • 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.
  • 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.log several 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();
    Asynchronous Icon async console output
  • Look closely at the output from that code example. We wanted to loop over the email list, emailing each user. We used a for loop, 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. The sendEmails function has no way to know that that happened. It continues looping, sending an email to emails[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 sendEmails looks fine on its own, and sortEmailsAfterDelay also 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 sendEmails and sortEmailsAfterDelay. 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 like Array<T>, except that ReadonlyArray<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:
    3Pass Icon
  • >
    const numbers: ReadonlyArray<number> = [1, 2, 3];
    numbers.sort();
    Result:
    type error: Property 'sort' does not exist on type 'readonly number[]'.Pass Icon
  • >
    const strings: ReadonlyArray<string> = ['a', 'b', 'c'];
    strings.indexOf('b');
    Result:
    1Pass Icon
  • >
    const strings: ReadonlyArray<string> = ['a', 'b', 'c'];
    strings.push('d');
    Result:
    type error: Property 'push' does not exist on type 'readonly string[]'.Pass Icon
  • Take a look at that last error message. We declared our type as ReadonlyArray<string>, but the error message reported it as readonly string[]. These are two different ways of writing the same type, just as Array<string> and string[] are the same type.

  • We can use ReadonlyArray to prevent our email sorting bug. If our emails array is a ReadonlyArray, rather than a regular Array, then calling emails.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[]'.Pass Icon
  • This is a great solution, as long as we remember to actually use ReadonlyArray. For that reason, it's a good idea to use ReadonlyArray by 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 regular Array<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']Pass Icon
  • >
    const originalLetters: ReadonlyArray<string> = ['b', 'c', 'a'];
    const slicedLetters = originalLetters.slice();
    const sortedLetters = slicedLetters.sort();
    originalLetters;
    Result:
    ['b', 'c', 'a']Pass Icon
  • We can safely use Arrays as ReadonlyArrays. 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'Pass Icon
  • Here's a code problem:

    The append function below adds a string to the end of an array of strings, returning a new array with that string added. It takes an Array<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 append function'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']]Pass Icon
  • 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 type ReadonlyArray<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]Pass Icon
  • 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 ReadonlyArray type 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 of ReadonlyArray. When we write a function that takes a ReadonlyArray<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 ReadonlySet and ReadonlyMap types. They work like the Set and Map types, except they don't allow any method calls that would mutate the set's or map's contents. (You may not have seen the Map type yet.)

  • In a later lesson, we'll see other ways to make data read-only, most notably in objects.