Skip to content

Commit 4f69ecf

Browse files
committed
Twoslashed mutability
1 parent 9b29018 commit 4f69ecf

File tree

1 file changed

+124
-65
lines changed

1 file changed

+124
-65
lines changed

book-content/chapters/07-mutability.md

Lines changed: 124 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,12 @@ type AlbumAttributes = {
122122

123123
Say we had an `updateStatus` function that takes an `AlbumAttributes` object:
124124

125-
```typescript
125+
```ts twoslash
126+
// @errors: 2345
127+
type AlbumAttributes = {
128+
status: "new-release" | "on-sale" | "staff-pick";
129+
};
130+
// ---cut---
126131
const updateStatus = (attributes: AlbumAttributes) => {
127132
// ...
128133
};
@@ -131,16 +136,10 @@ const albumAttributes = {
131136
status: "on-sale",
132137
};
133138

134-
updateStatus(albumAttributes); // red squiggly line under albumAttributes
139+
updateStatus(albumAttributes);
135140
```
136141

137-
TypeScript gives us an error below `albumAttributes` inside of the `updateStatus` function call, with messages similar to what we saw before:
138-
139-
```
140-
Argument of type '{ status: string; }' is not assignable to parameter of type 'AlbumAttributes'.
141-
Types of property 'status' are incompatible.
142-
Type 'string' is not assignable to type '"new-release" | "on-sale" | "staff-pick"'.
143-
```
142+
TypeScript gives us an error below `albumAttributes` inside of the `updateStatus` function call, with messages similar to what we saw before.
144143

145144
This is happening because TypeScript has inferred the `status` property as a `string` rather than the specific literal type `"on-sale"`. Similar to with `let`, TypeScript understands that the property could later be reassigned:
146145

@@ -223,9 +222,23 @@ const readOnlyWhiteAlbum: Readonly<Album> = {
223222

224223
Because the `readOnlyWhiteAlbum` object was created using the `Readonly` type helper, none of the properties can be modified:
225224

226-
```typescript
227-
readOnlyWhiteAlbum.genre = ["rock", "pop", "unclassifiable"]; // red squiggly line under genre
228-
// Cannot assign to 'genre' because it is a read-only property
225+
```ts twoslash
226+
// @errors: 2540
227+
type Album = {
228+
title: string;
229+
artist: string;
230+
status?: "new-release" | "on-sale" | "staff-pick";
231+
genre?: string[];
232+
};
233+
234+
const readOnlyWhiteAlbum: Readonly<Album> = {
235+
title: "The Beatles (White Album)",
236+
artist: "The Beatles",
237+
status: "staff-pick",
238+
};
239+
// ---cut---
240+
241+
readOnlyWhiteAlbum.genre = ["rock", "pop", "unclassifiable"];
229242
```
230243

231244
Note that like many of TypeScript's type helpers, the immutability enforced by `Readonly` only operates on the first level. It won't make properties read-only recursively.
@@ -255,20 +268,24 @@ const readOnlyGenres: readonly string[];
255268

256269
Readonly arrays disallow the use of array methods that cause mutations, such as `push` and `pop`:
257270

258-
```typescript
259-
readOnlyGenres.push("experimental"); // red squiggly line under push
260-
// Property 'push' does not exist on type 'readonly string[]'
271+
```ts twoslash
272+
// @errors: 2339
273+
const readOnlyGenres: readonly string[] = ["rock", "pop", "unclassifiable"];
274+
275+
// ---cut---
276+
readOnlyGenres.push("experimental");
261277
```
262278

263279
However, methods like `map` and `reduce` will still work, as they create a copy of the array and do not mutate the original.
264280

265-
```typescript
266-
const uppercaseGenres = readOnlyGenres.map((genre) => genre.toUpperCase()); // No error
281+
```ts twoslash
282+
// @errors: 2339
283+
const readOnlyGenres: readonly string[] = ["rock", "pop", "unclassifiable"];
267284

268-
readOnlyGenres.push("experimental"); // red squiggly line under push
285+
// ---cut---
286+
const uppercaseGenres = readOnlyGenres.map((genre) => genre.toUpperCase()); // No error
269287

270-
// hovering over push shows:
271-
Property 'push' does not exist on type 'readonly string[]'
288+
readOnlyGenres.push("experimental");
272289
```
273290

274291
Note that, just like the `readonly` for object properties, this doesn't affect the runtime behavior of the array. It's just a way to help catch potential errors.
@@ -304,14 +321,21 @@ However, the reverse is not true.
304321

305322
If we declare a read-only array, we can only pass it to `printGenresReadOnly`. Attempting to pass it to `printGenresMutable` will yield an error:
306323

307-
```typescript
324+
```ts twoslash
325+
// @errors: 2345
326+
function printGenresReadOnly(genres: readonly string[]) {
327+
// ...
328+
}
329+
330+
function printGenresMutable(genres: string[]) {
331+
// ...
332+
}
333+
334+
// ---cut---
308335
const readOnlyGenres: readonly string[] = ["rock", "pop", "unclassifiable"];
309336

310337
printGenresReadOnly(readOnlyGenres);
311-
printGenresMutable(readOnlyGenres); // red squiggly line under readOnlyGenres
312-
313-
// hovering over readOnlyGenres shows:
314-
// Error: Argument of type 'readonly ["rock", "pop", "unclassifiable"]' is not assignable to parameter of type 'string[]'
338+
printGenresMutable(readOnlyGenres);
315339
```
316340

317341
This is because we might be mutating the array inside of `printGenresMutable`. If we passed a read-only array.
@@ -328,7 +352,8 @@ Here we have a `modifyButtons` function that takes in an array of objects with `
328352

329353
When attempting to call `modifyButtons` with an array of objects that seem to meet the contract, TypeScript gives us an error:
330354

331-
```typescript
355+
```ts twoslash
356+
// @errors: 2345
332357
type ButtonAttributes = {
333358
type: "button" | "submit" | "reset";
334359
};
@@ -344,7 +369,7 @@ const buttonsToChange = [
344369
},
345370
];
346371

347-
modifyButtons(buttonsToChange); // red squiggly line under buttonsToChange
372+
modifyButtons(buttonsToChange);
348373
```
349374

350375
Your task is to determine why this error shows up, then resolve it.
@@ -353,16 +378,17 @@ Your task is to determine why this error shows up, then resolve it.
353378

354379
This `printNames` function accepts an array of `name` strings and logs them to the console. However, there are also non-working `@ts-expect-error` comments that should not allow for names to be added or changed:
355380

356-
```typescript
381+
```ts twoslash
382+
// @errors: 2578
357383
function printNames(names: string[]) {
358384
for (const name of names) {
359385
console.log(name);
360386
}
361387

362-
// @ts-expect-error // red squiggly line
388+
// @ts-expect-error
363389
names.push("John");
364390

365-
// @ts-expect-error // red squiggly line
391+
// @ts-expect-error
366392
names[0] = "Billy";
367393
}
368394
```
@@ -393,9 +419,19 @@ Given that `pop` removes the last element from an array, calling `dangerousFunct
393419

394420
Currently, TypeScript does not alert us to this potential issue, as seen by the error line under `@ts-expect-error`:
395421

396-
```typescript
422+
```ts twoslash
423+
// @errors: 2578
424+
type Coordinate = [number, number];
425+
const myHouse: Coordinate = [0, 0];
426+
427+
const dangerousFunction = (arrayOfNumbers: number[]) => {
428+
arrayOfNumbers.pop();
429+
arrayOfNumbers.pop();
430+
};
431+
432+
// ---cut---
397433
dangerousFunction(
398-
// @ts-expect-error // red squiggly line under @ts-expect-error
434+
// @ts-expect-error
399435
myHouse,
400436
);
401437
```
@@ -504,20 +540,18 @@ type Coordinate = readonly [number, number];
504540

505541
Now, `dangerousFunction` throws a TypeScript error when we try to pass `myHouse` to it:
506542

507-
```typescript
543+
```ts twoslash
544+
// @errors: 2345
545+
type Coordinate = readonly [number, number];
546+
const myHouse: Coordinate = [0, 0];
547+
548+
// ---cut---
508549
const dangerousFunction = (arrayOfNumbers: number[]) => {
509550
arrayOfNumbers.pop();
510551
arrayOfNumbers.pop();
511552
};
512553

513-
dangerousFunction(
514-
// @ts-expect-error
515-
myHouse, // red squiggly line under myHouse
516-
);
517-
518-
// hovering over myHouse shows:
519-
// Argument of type 'Coordinate' is not assignable to parameter of type 'number[]'.
520-
// The type 'Coordinate' is 'readonly' and cannot be assigned to the mutable type 'number[]'.
554+
dangerousFunction(myHouse);
521555
```
522556

523557
We get an error because the function's signature expects a modifiable array of numbers, but `myHouse` is a read-only tuple. TypeScript is protecting us against unwanted changes.
@@ -562,8 +596,14 @@ The `as const` assertion has made the entire object deeply read-only, including
562596

563597
Attempting to change the `status` property will result in an error:
564598

565-
```typescript
566-
albumAttributes.status = "new-release"; // red squiggly line under status
599+
```ts twoslash
600+
// @errors: 2540
601+
const albumAttributes = {
602+
status: "on-sale",
603+
} as const;
604+
605+
// ---cut---
606+
albumAttributes.status = "new-release";
567607
```
568608

569609
This makes `as const` ideal for large config objects that you don't expect to change.
@@ -631,13 +671,26 @@ const shelfLocations: Readonly<{
631671
}>;
632672
```
633673

634-
Recall that the `Readonly` modifier only works on the _first level_ of an object. If we try to add a new `backWall` property to the `shelfLocations` object, TypeScript will throw an error:
674+
Recall that the `Readonly` modifier only works on the _first level_ of an object. If we try to modify the `frontCounter` property, TypeScript will throw an error:
635675

636-
```typescript
637-
shelfLocations.backWall = { status: "on-sale" }; // red squiggly line under backWall
676+
```ts twoslash
677+
// @errors: 2540
678+
const shelfLocations = Object.freeze({
679+
entrance: {
680+
status: "on-sale",
681+
},
682+
frontCounter: {
683+
status: "staff-pick",
684+
},
685+
endCap: {
686+
status: "new-release",
687+
},
688+
});
638689

639-
// hovering over backWall shows:
640-
// Property 'backWall' does not exist on type 'Readonly<{ entrance: { status: string; }; frontCounter: { status: string; }; endCap: { status: string; }; }>'
690+
// ---cut---
691+
shelfLocations.frontCounter = {
692+
status: "new-release",
693+
};
641694
```
642695

643696
However, we are able to change the nested `status` property of a specific location:
@@ -650,7 +703,7 @@ This is in line with how `Object.freeze` works in JavaScript. It only makes the
650703

651704
Using `as const` makes the entire object deeply read-only, including all nested properties:
652705

653-
```typescript
706+
```ts twoslash
654707
const shelfLocations = {
655708
entrance: {
656709
status: "on-sale",
@@ -663,18 +716,8 @@ const shelfLocations = {
663716
},
664717
} as const;
665718

666-
// hovering over shelfLocations shows:
667-
const shelfLocations: {
668-
readonly entrance: {
669-
readonly status: "on-sale";
670-
};
671-
readonly frontCounter: {
672-
readonly status: "staff-pick";
673-
};
674-
readonly endCap: {
675-
readonly status: "new-release";
676-
};
677-
};
719+
console.log(shelfLocations);
720+
// ^?
678721
```
679722

680723
Of course, this is just a type-level annotation. `Object.freeze` gives you runtime immutability, while `as const` gives you type-level immutability. I actually prefer the latter - doing less work at runtime is always a good thing.
@@ -707,12 +750,27 @@ const fetchData = async () => {
707750

708751
Here's an async `example` function that uses `fetchData` and includes a couple of test cases:
709752

710-
```typescript
753+
```ts twoslash
754+
// @errors: 2344
755+
import { Equal, Expect } from "@total-typescript/helpers";
756+
757+
const fetchData = async () => {
758+
const result = await fetch("/");
759+
760+
if (!result.ok) {
761+
return [new Error("Could not fetch data.")];
762+
}
763+
764+
const data = await result.json();
765+
766+
return [undefined, data];
767+
};
768+
// ---cut---
711769
const example = async () => {
712770
const [error, data] = await fetchData();
713771

714772
type Tests = [
715-
Expect<Equal<typeof error, Error | undefined>>, // red squiggly line under Equal<>
773+
Expect<Equal<typeof error, Error | undefined>>,
716774
Expect<Equal<typeof data, any>>,
717775
];
718776
};
@@ -740,7 +798,8 @@ Let's revisit a previous exercise and evolve our solution.
740798

741799
The `modifyButtons` function accepts an array of objects with a `type` property:
742800

743-
```typescript
801+
```ts twoslash
802+
// @errors: 2345
744803
type ButtonAttributes = {
745804
type: "button" | "submit" | "reset";
746805
};
@@ -756,7 +815,7 @@ const buttonsToChange = [
756815
},
757816
];
758817

759-
modifyButtons(buttonsToChange); // red squiggly line under buttonsToChange
818+
modifyButtons(buttonsToChange);
760819
```
761820

762821
Previously, the error was solved by updating `buttonsToChange` to be specified as an array of `ButtonAttributes`:

0 commit comments

Comments
 (0)