Skip to content

Commit 3d877f4

Browse files
committed
Edit the post
1 parent 7cbea83 commit 3d877f4

File tree

2 files changed

+23
-28
lines changed

2 files changed

+23
-28
lines changed

posts/2019-01-19_Expressive-React-Component-APIs-with-Discriminated-Unions.md

+20-28
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,6 @@ name: shapes.ts
3737
interface Polygon {
3838
numberOfSides: number;
3939
sideLengths: number[];
40-
angles: number[];
41-
getArea(): number;
42-
getPerimeter(): number;
43-
isRegular(): boolean;
4440
}
4541

4642
enum TriangleKind {
@@ -54,18 +50,9 @@ interface Triangle extends Polygon {
5450
triangleKind: TriangleKind;
5551
}
5652

57-
enum QuadrilateralFlags {
58-
Parallelogram = 1 << 0,
59-
Rectangle = 1 << 1,
60-
Square = 1 << 2,
61-
Rhombus = 1 << 3,
62-
Trapezoid = 1 << 4,
63-
Kite = 1 << 5
64-
}
65-
6653
interface Quadrilateral extends Polygon {
6754
numberOfSides: 4;
68-
quadrilateralFlags: QuadrilateralFlags;
55+
isRectangle: boolean;
6956
}
7057
```
7158

@@ -78,19 +65,21 @@ name: shapes.ts
7865
function addShape(shape: Triangle | Quadrilateral) {
7966
if (shape.numberOfSides === 3) {
8067
// In here, the compiler knows that `shape` is a `Triangle`,
81-
// so we can access triangle-specific properties
68+
// so we can access triangle-specific properties.
69+
// See for yourself: hover each occurance of “shape” and
70+
// compare the typing info.
8271
console.log(shape.triangleKind);
8372
} else {
8473
// In here, the compiler knows that `shape` is a `Quadrilateral`.
85-
console.log(shape.quadrilateralFlags);
74+
console.log(shape.isRectangle);
8675
}
8776
}
8877
```
8978

90-
When we have a union (like `Triangle | Quadrilateral`) that can be narrowed by a specific property (like `numberOfSides`), that union is called a _discriminated union_ and that property is called the _discriminant property_.
79+
When we have a union (like `Triangle | Quadrilateral`) that can be narrowed by a literal member (like `numberOfSides`), that union is called a _discriminated union_ and that property is called the _discriminant property_.
9180

92-
## Do these props look too loose on me?
93-
You’re writing a Select component (i.e., a fancy replacement for an HTMLSelectElement) with React and TypeScript. Perhaps you look at the [`SelectHTMLAttributes` interface](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/eda212cfd64119cf2edc2f4ab12e53c4654a9b87/types/react/index.d.ts#L1979-L1990) from [`@types/react`](https://www.npmjs.com/package/@types/react) for inspiration, and notice that a native select element, in React, can have a `value` of type `string | string[] | number`. From TypeScript’s perspective, you can pass a single value or an array of values indiscriminately, but you know that an array of values is really only meaningful if the `multiple` prop is set. Nonetheless, you try this approach for your component:
81+
## The problem: Overly permissive props
82+
You’re writing a Select component (i.e., a fancy replacement for an HTMLSelectElement) with React and TypeScript. You want it to support both single-selection and multiple-selection, just like a native select element. Perhaps you look at the [`SelectHTMLAttributes` interface](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/eda212cfd64119cf2edc2f4ab12e53c4654a9b87/types/react/index.d.ts#L1979-L1990) from [`@types/react`](https://www.npmjs.com/package/@types/react) for inspiration, and notice that a native select element, in React, can have a `value` of type `string | string[] | number`. From TypeScript’s perspective, you can pass a single value or an array of values indiscriminately, but you know that an array of values is really only meaningful if the `multiple` prop is set. Nonetheless, you try this approach for your component:
9483

9584
<!--@
9685
name: select-1.tsx
@@ -115,7 +104,8 @@ The idea is that when `multiple` is `true`, the consumer should set `value` to a
115104
name: select-1.tsx
116105
-->
117106
```tsx
118-
// Missing `multiple` prop, but no compiler error
107+
// Value is an array, but it’s missing the `multiple`
108+
// prop, but no compiler error
119109
<Select
120110
options={['Red', 'Green', 'Blue']}
121111
value={['Red', 'Blue']}
@@ -174,7 +164,7 @@ class Select extends React.Component<SelectProps> {
174164
}
175165
```
176166

177-
As triangles and quadrilaterals can be distinguished by their number of sides, the union type `SelectProps` can be discriminated by its `multiple` property. And as luck would have it, TypeScript will do exactly that when you pass (or don’t pass) the `multiple` prop to your new and improved component:
167+
As triangles and quadrilaterals can be distinguished by their number of sides, the union type `SelectProps` can be discriminated by its `multiple` property. And as luck would have it, TypeScript will do exactly that when you pass (or don’t pass) the `multiple` prop to your new and improved component: [^1]
178168

179169
<!--@
180170
name: select-2.tsx
@@ -209,7 +199,7 @@ name: select-2.tsx
209199
Whoa, this is a bazillion times better! Nice work; consumers of your component will thank you for coaching them down the right path _before_ they run their code in a browser. 🎉
210200

211201
## Going deeper with the distributive law of sets
212-
Time goes by. Your Select component was a big hit with the other developers who were using it. Maybe you got a promotion. But then, the design team shows you specs for a Select component with _groups_ of options, with customizable titles for each group. You start prototyping the props you’ll have to add in your head:
202+
Time goes by. Your Select component was a big hit with the other developers who were using it. But then, the design team shows you specs for a Select component with _groups_ of options, with customizable titles for each group. You start prototyping the props you’ll have to add in your head:
213203

214204
```ts
215205
type OptionGroup = {
@@ -220,7 +210,7 @@ type OptionGroup = {
220210
interface YourMentalModelOfChangesToSelectProps {
221211
grouped?: boolean;
222212
options: string[] | OptionGroup[];
223-
renderGroupTitle?: (group: OptionGroup) => JSX.Element;
213+
renderGroupTitle?: (group: OptionGroup) => React.ReactNode;
224214
}
225215
```
226216

@@ -233,7 +223,7 @@ With two different choices to make (multiple and grouped), each with two options
233223
3. single selection, grouped
234224
4. multiple selection, grouped.
235225

236-
Writing each of those options out as a complete interface of possible Select props and creating a union of all four isn’t unthinkably tedious, but the relationship is exponential: three boolean choices makes a union of 2^3 = 8, four choices is 16, and so on. Rather sooner than later, it becomes unwieldy to express every combination of essentially unrelated choices explicitly.
226+
Writing each of those options out as a complete interface of possible Select props and creating a union of all four isn’t unthinkably tedious, but the relationship is exponential: three boolean choices makes a union of $2^3 = 8$, four choices is 16, and so on. Rather sooner than later, it becomes unwieldy to express every combination of essentially unrelated choices explicitly.
237227

238228
You can avoid repeating yourself and writing out every combination by taking advantage of some set theory. Instead of writing four complete interfaces that repeat props from each other, you can write interfaces for each discrete piece of functionality and combine them via intersection:
239229

@@ -285,7 +275,7 @@ class Select extends React.Component<SelectProps> {
285275

286276
Let’s break down what happened here:
287277

288-
1. For each constituent in the union, we removed its `extends` clause so the interface reflects only a discrete subset of functionality that can be intersected cleanly with anything else. (In this example, that’s not strictly necessary, but I think it’s cleaner, and I have an unverified theory that it’s less work for the compiler.[^1]) To reflect this change in our naming, we also suffixed each interface with `Fragment` to be clear that it’s not a complete working set of Select props.
278+
1. For each constituent in the union, we removed its `extends` clause so the interface reflects only a discrete subset of functionality that can be intersected cleanly with anything else. (In this example, that’s not strictly necessary, but I think it’s cleaner, and I have an unverified theory that it’s less work for the compiler.[^2]) To reflect this change in our naming, we also suffixed each interface with `Fragment` to be clear that it’s not a complete working set of Select props.
289279
2. We broke down grouped and non-grouped selects into two interfaces discriminated on `grouped`, just like we did before with `multiple`.
290280
3. We combined everything together with an intersection of unions. In plain English, SelectProps is made up of:
291281
- `CommonSelectProps`, along with
@@ -307,7 +297,7 @@ $$
307297
If, like me, you haven’t studied computer science in an academic setting, this may look intimidatingly theoretical, but quickly make the following mental substitutions:
308298

309299
- Set theory’s union operator, $\cup$, is written as `|` in TypeScript
310-
- Set theory’s intersection operator, $\cap$, is written as `&` in TypeScript[^2]
300+
- Set theory’s intersection operator, $\cap$, is written as `&` in TypeScript[^3]
311301
- Let $Z =$ `CommonSelectProps`
312302
- Let $A =$ `SingleSelectPropsFragment`
313303
- Let $B =$ `MultipleSelectPropsFragment`
@@ -356,8 +346,10 @@ Discriminated unions can be a powerful tool for writing better React component t
356346
- [Tagged union - Wikipedia](https://en.wikipedia.org/wiki/Tagged_union)
357347

358348
[^1]:
359-
My hypothesis is that in calculating the intersection of _N_ types that all include common properties, the compiler must calculate for each of _n_ common properties of type _T_ that _T_ intersected with itself _N_ times is still _T_. This is surely not a computationally expensive code path, but unless there’s a clever short circuit early in the calculation, it still has to happen _N ⨉ n_ times, all of which are unnecessary. This is purely unscientific speculation, and I would be happy for someone to correct or corroborate this theory.
349+
Interestingly, in the final case here, the explicit value `multiple={false}` is required not to pass type checking, but to get accurate inference on the argument to `onChange`. This seems like a limitation/bug to me.
360350
[^2]:
361-
This statement applies only in the type declaration space. `|` and `&` are bitwise operators in the variable declaration space. E.g., `|` is the union operator in `var x: string | number` but the bitwise _or_ operator in `var x = 0xF0 | 0x0F`.
351+
My hypothesis is that in calculating the intersection of _N_ types that all include common properties, the compiler must calculate for each of _n_ common properties of type _T_ that _T_ intersected with itself _N_ times is still _T_. This is surely not a computationally expensive code path, but unless there’s a clever short circuit early in the calculation, it still has to happen _N ⨉ n_ times, all of which are unnecessary. This is purely unscientific speculation, and I would be happy for someone to correct or corroborate this theory.
362352
[^3]:
353+
This statement applies only in the type declaration space. `|` and `&` are bitwise operators in the variable declaration space. E.g., `|` is the union operator in `var x: string | number` but the bitwise _or_ operator in `var x = 0xF0 | 0x0F`.
354+
[^4]:
363355
TypeScript does successfully discriminate between these constituents, but type inference [is currently broken](https://github.com/Microsoft/TypeScript/issues/29340) for properties that have different function signatures in different constituents when any of those constituents are an intersection type.

src/components/Layout.tsx

+3
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ const Layout: React.FunctionComponent = ({ children }) => {
2727
'.katex': {
2828
fontSize: '0.9rem',
2929
},
30+
'p > .katex, li > .katex': {
31+
padding: '0 2px',
32+
},
3033
'.katex-display': {
3134
margin: 0,
3235
// Get the potential scrollbar out of the way of the content

0 commit comments

Comments
 (0)