Skip to content

Commit 2b7e6a7

Browse files
authored
Merge pull request #25 from total-typescript/matt/added-section-on-automatically-inferred-type-predicates-for-5.5
Added section on automatically inferred type predicates for 5.5
2 parents f0fd9ea + 647b244 commit 2b7e6a7

File tree

5 files changed

+332
-29
lines changed

5 files changed

+332
-29
lines changed

book-content/chapters/05-unions-literals-and-narrowing.md

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -904,6 +904,54 @@ it("Should error when anything else is passed in", () => {
904904

905905
Your challenge is to modify the `parseValue` function so that the tests pass and the errors go away. I want you to challenge yourself to do this _only_ by narrowing the type of `value` inside of the function. No changes to the types. This will require a very large `if` statement!
906906

907+
#### Exercise 3: Reusable Type Guards
908+
909+
Let's imagine that we have two very similar functions, each with a long conditional check to narrow down the type of a value.
910+
911+
Here's the first function:
912+
913+
```typescript
914+
const parseValue = (value: unknown) => {
915+
if (
916+
typeof value === "object" &&
917+
value !== null &&
918+
"data" in value &&
919+
typeof value.data === "object" &&
920+
value.data !== null &&
921+
"id" in value.data &&
922+
typeof value.data.id === "string"
923+
) {
924+
return value.data.id;
925+
}
926+
927+
throw new Error("Parsing error!");
928+
};
929+
```
930+
931+
And here's the second function:
932+
933+
```typescript
934+
const parseValueAgain = (value: unknown) => {
935+
if (
936+
typeof value === "object" &&
937+
value !== null &&
938+
"data" in value &&
939+
typeof value.data === "object" &&
940+
value.data !== null &&
941+
"id" in value.data &&
942+
typeof value.data.id === "string"
943+
) {
944+
return value.data.id;
945+
}
946+
947+
throw new Error("Parsing error!");
948+
};
949+
```
950+
951+
Both functions have the same conditional check. This is a great opportunity to create a reusable type guard.
952+
953+
All the tests are currently passing. Your job is to try to refactor the two functions to use a reusable type guard, and remove the duplicated code. As it turns out, TypeScript makes this a lot easier than you expect.
954+
907955
#### Solution 1: Narrowing Errors with `instanceof`
908956

909957
The way to solve this challenge is to narrow the `error` using the `instanceof` operator.
@@ -1034,6 +1082,59 @@ Thanks to this huge conditional, our tests pass, and our error messages are gone
10341082

10351083
This is usually _not_ how you'd want to write your code. It's a bit of a mess. You could use a library like [Zod](https://zod.dev) to do this with a much nicer API. But it's a great way to understand how `unknown` and narrowing work in TypeScript.
10361084

1085+
#### Solution 3: Reusable Type Guards
1086+
1087+
The first step is to create a function called `hasDataId` that captures the conditional check:
1088+
1089+
```typescript
1090+
const hasDataId = (value) => {
1091+
return (
1092+
typeof value === "object" &&
1093+
value !== null &&
1094+
"data" in value &&
1095+
typeof value.data === "object" &&
1096+
value.data !== null &&
1097+
"id" in value.data &&
1098+
typeof value.data.id === "string"
1099+
);
1100+
};
1101+
```
1102+
1103+
We haven't given `value` a type here - `unknown` makes sense, because it could be anything.
1104+
1105+
Now we can refactor the two functions to use this type guard:
1106+
1107+
```typescript
1108+
const parseValue = (value: unknown) => {
1109+
if (hasDataId(value)) {
1110+
return value.data.id;
1111+
}
1112+
1113+
throw new Error("Parsing error!");
1114+
};
1115+
1116+
const parseValueAgain = (value: unknown) => {
1117+
if (hasDataId(value)) {
1118+
return value.data.id;
1119+
}
1120+
1121+
throw new Error("Parsing error!");
1122+
};
1123+
```
1124+
1125+
Incredibly, this is all TypeScript needs to be able to narrow the type of `value` inside of the `if` statement. It's smart enough to understand that `hasDataId` being called on `value` ensures that `value` has a `data` property with an `id` property.
1126+
1127+
We can observe this by hovering over `hasDataId`:
1128+
1129+
```typescript
1130+
// hovering over `hasDataId` shows:
1131+
const hasDataId: (value: unknown) => value is { data: { id: string } };
1132+
```
1133+
1134+
This return type we're seeing is a type predicate. It's a way of saying "if this function returns `true`, then the type of the value is `{ data: { id: string } }`".
1135+
1136+
We'll look at authoring our own type predicates in one of the later chapters in the book - but it's very useful that TypeScript infers its own.
1137+
10371138
## Discriminated Unions
10381139

10391140
In this section we'll look at a common pattern TypeScript developers use to structure their code. It's called a 'discriminated union'.

book-content/chapters/16-the-utils-folder.md

Lines changed: 64 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -326,14 +326,14 @@ Note how clever TypeScript is being here. Even though we didn't specify a return
326326

327327
## Type Predicates
328328

329-
A type predicate is a special return type that tells TypeScript that a function returns a Boolean value that says something about the type of one of its parameters.
329+
We were introduced to type predicates way back in chapter 5, when we looked at narrowing. They're used to capture reusable logic that narrows the type of a variable.
330330

331331
For example, say we want to ensure that a variable is an `Album` before we try accessing its properties or passing it to a function that requires an `Album`.
332332

333-
We can write an `isAlbum` function that takes in an input, and specifies a return type of `input is Album`. The body of the function will check that the `input` passed in is a non-null object with the required properties of an `Album`:
333+
We can write an `isAlbum` function that takes in an input, and checks for all the required properties.
334334

335335
```typescript
336-
function isAlbum(input: unknown): input is Album {
336+
function isAlbum(input: unknown) {
337337
return (
338338
typeof input === "object" &&
339339
input !== null &&
@@ -345,55 +345,91 @@ function isAlbum(input: unknown): input is Album {
345345
}
346346
```
347347

348-
The `input is Album` return type is the type predicate that tells TypeScript that if the function returns `true`, then the `input` parameter is of type `Album`. Otherwise, it isn't.
348+
If we hover over `isAlbum`, we can see a rather ugly type signature:
349349

350-
### Narrowing with Type Predicates
350+
```typescript
351+
// hovering over isAlbum shows:
352+
function isAlbum(
353+
input: unknown,
354+
): input is object &
355+
Record<"id", unknown> &
356+
Record<"title", unknown> &
357+
Record<"artist", unknown> &
358+
Record<"year", unknown>;
359+
```
351360

352-
Type predicates are often used in conditional statements to narrow the type of a variable to become more specific.
361+
This is technically correct: a big intersection between an `object` and a bunch of `Record`s. But it's not very helpful.
353362

354-
For example, we can use the `isAlbum` type predicate to check if an `item` is an `Album` before accessing its `title` property:
363+
When we try to use `isAlbum` to narrow the type of a value, TypeScript will infer lots of the values as `unknown`:
355364

356365
```typescript
357-
function getAlbumTitle(item: unknown) {
358-
if (isAlbum(item)) {
359-
return item.title;
366+
const run = (maybeAlbum: unknown) => {
367+
if (isAlbum(maybeAlbum)) {
368+
maybeAlbum.name.toUpperCase(); // red squiggly line under name
360369
}
361-
return "Unknown Album";
362-
}
370+
};
371+
372+
// hovering over name shows:
373+
// Object is of type 'unknown'.
363374
```
364375

365-
In this case, the `getAlbumTitle` function takes an `item` of type `unknown`. Inside the function, we use the `isAlbum` type predicate to check if the `item` is an `Album`. If it is, TypeScript narrows the type of `item` to `Album` within the conditional block, allowing us to access the `title` property without any type errors:
376+
To fix this, we'd need to add even more checks to `isAlbum` to ensure we're checking the types of all the properties:
366377

367378
```typescript
368-
let title = getAlbumTitle({
369-
id: 1,
370-
title: "Dummy",
371-
artist: "Portishead",
372-
year: 1994,
373-
});
374-
375-
console.log(title); // "Dummy"
379+
function isAlbum(input: unknown) {
380+
return (
381+
typeof input === "object" &&
382+
input !== null &&
383+
"id" in input &&
384+
"title" in input &&
385+
"artist" in input &&
386+
"year" in input &&
387+
typeof input.id === "number" &&
388+
typeof input.title === "string" &&
389+
typeof input.artist === "string" &&
390+
typeof input.year === "number"
391+
);
392+
}
393+
```
376394

377-
let notAnAlbumTitle = getAlbumTitle("Some string");
395+
This can feel far too verbose. We can make it more readable by adding our own type predicate.
378396

379-
console.log(notAnAlbumTitle); // "Unknown Album"
397+
```typescript
398+
function isAlbum(input: unknown): input is Album {
399+
return (
400+
typeof input === "object" &&
401+
input !== null &&
402+
"id" in input &&
403+
"title" in input &&
404+
"artist" in input &&
405+
"year" in input
406+
);
407+
}
380408
```
381409

382-
### Type Predicates Can be Unsafe
410+
Now, when we use `isAlbum`, TypeScript will know that the type of the value has been narrowed to `Album`:
383411

384-
Type predicates are a great technique to be aware of, and are particularly useful when working with union types.
412+
```typescript
413+
const run = (maybeAlbum: unknown) => {
414+
if (isAlbum(maybeAlbum)) {
415+
maybeAlbum.name.toUpperCase(); // No error!
416+
}
417+
};
418+
```
385419

386-
However, there are situations where they aren't as type-safe as they may appear.
420+
For complex type guards, this can be much more readable.
421+
422+
### Type Predicates Can be Unsafe
387423

388-
For example, if the type predicate doesn't match the actual type being checked, TypeScript won't catch that discrepancy:
424+
Authoring your own type predicates can be a little dangerous. If the type predicate doesn't accurately reflect the type being checked, TypeScript won't catch that discrepancy:
389425

390426
```typescript
391427
function isAlbum(input): input is Album {
392428
return typeof input === "object";
393429
}
394430
```
395431

396-
In this case, any object passed to `isAlbum` will be considered an `Album`, even if it doesn't have the required properties. This is a common pitfall when working with type predicates, and it's important to ensure that the type predicate accurately reflects the type being checked.
432+
In this case, any object passed to `isAlbum` will be considered an `Album`, even if it doesn't have the required properties. This is a common pitfall when working with type predicates - it's important to consider them about as unsafe as `as` and `!`.
397433

398434
## Assertion Functions
399435

package.json

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -568,7 +568,27 @@
568568
"e-181.2": "tt-cli run 181.2",
569569
"s-181.2": "tt-cli run 181.2 --solution",
570570
"e-181.4": "tt-cli run 181.4",
571-
"s-181.4": "tt-cli run 181.4 --solution"
571+
"s-181.4": "tt-cli run 181.4 --solution",
572+
"e-104.5": "tt-cli run 104.5",
573+
"s-104.5": "tt-cli run 104.5 --solution",
574+
"e-082.5": "tt-cli run 082.5",
575+
"s-082.5": "tt-cli run 082.5 --solution",
576+
"e-086.5": "tt-cli run 086.5",
577+
"s-086.5": "tt-cli run 086.5 --solution",
578+
"e-080.5": "tt-cli run 080.5",
579+
"s-080.5": "tt-cli run 080.5 --solution",
580+
"e-154.8": "tt-cli run 154.8",
581+
"s-154.8": "tt-cli run 154.8 --solution",
582+
"e-154.9": "tt-cli run 154.9",
583+
"s-154.9": "tt-cli run 154.9 --solution",
584+
"e-196.5": "tt-cli run 196.5",
585+
"s-196.5": "tt-cli run 196.5 --solution",
586+
"e-202.5": "tt-cli run 202.5",
587+
"s-202.5": "tt-cli run 202.5 --solution",
588+
"e-202.6": "tt-cli run 202.6",
589+
"s-202.6": "tt-cli run 202.6 --solution",
590+
"e-202.7": "tt-cli run 202.7",
591+
"s-202.7": "tt-cli run 202.7 --solution"
572592
},
573593
"dependencies": {
574594
"@tanstack/react-query": "^4.29.12",
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { Equal, Expect } from "@total-typescript/helpers";
2+
import { describe, expect, it } from "vitest";
3+
4+
const parseValue = (value: unknown) => {
5+
if (
6+
typeof value === "object" &&
7+
value !== null &&
8+
"data" in value &&
9+
typeof value.data === "object" &&
10+
value.data !== null &&
11+
"id" in value.data &&
12+
typeof value.data.id === "string"
13+
) {
14+
return value.data.id;
15+
}
16+
17+
throw new Error("Parsing error!");
18+
};
19+
20+
const parseValueAgain = (value: unknown) => {
21+
if (
22+
typeof value === "object" &&
23+
value !== null &&
24+
"data" in value &&
25+
typeof value.data === "object" &&
26+
value.data !== null &&
27+
"id" in value.data &&
28+
typeof value.data.id === "string"
29+
) {
30+
return value.data.id;
31+
}
32+
33+
throw new Error("Parsing error!");
34+
};
35+
36+
describe("parseValue", () => {
37+
it("Should handle a { data: { id: string } }", () => {
38+
const result = parseValue({
39+
data: {
40+
id: "123",
41+
},
42+
});
43+
44+
type test = Expect<Equal<typeof result, string>>;
45+
46+
expect(result).toBe("123");
47+
});
48+
49+
it("Should error when anything else is passed in", () => {
50+
expect(() => parseValue("123")).toThrow("Parsing error!");
51+
expect(() => parseValue(123)).toThrow("Parsing error!");
52+
});
53+
});
54+
55+
describe("parseValueAgain", () => {
56+
it("Should handle a { data: { id: string } }", () => {
57+
const result = parseValueAgain({
58+
data: {
59+
id: "123",
60+
},
61+
});
62+
63+
type test = Expect<Equal<typeof result, string>>;
64+
65+
expect(result).toBe("123");
66+
});
67+
68+
it("Should error when anything else is passed in", () => {
69+
expect(() => parseValueAgain("123")).toThrow("Parsing error!");
70+
expect(() => parseValueAgain(123)).toThrow("Parsing error!");
71+
});
72+
});

0 commit comments

Comments
 (0)