Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ I highly recommend to add a bounty to the issue that you're waiting for to incre
- [Action Creators 🌟](#action-creators-)
- [Reducers](#reducers)
- [State with Type-level Immutability](#state-with-type-level-immutability)
- [Modelling async data with ADT](#modelling-async-data-with-adt)
- [Typing reducer](#typing-reducer)
- [Typing reducer with `typesafe-actions`](#typing-reducer-with-typesafe-actions)
- [Testing reducer](#testing-reducer)
Expand Down Expand Up @@ -1491,6 +1492,94 @@ state.containerObject.numbers.push(1); // TS Error: cannot use mutator methods

[⇧ back to top](#table-of-contents)

### Modelling async data with ADT

Async reducer state is often written as several nullable fields and flags:

```ts
type TodosState = Readonly<{
isLoading: boolean,
error: string | null,
todos: ReadonlyArray<Todo> | null,
}>;
```

That shape accepts impossible combinations, for example `isLoading: true` with
both `error` and `todos` populated. A discriminated union makes every remote
data case explicit and keeps those combinations out of the state type.

```ts
type RemoteData<T, E = string> =
| { readonly tag: 'notAsked' }
| { readonly tag: 'loading' }
| { readonly tag: 'failure'; readonly error: E }
| { readonly tag: 'success'; readonly data: T };

type TodosState = Readonly<{
todos: RemoteData<ReadonlyArray<Todo>>,
}>;

const initialState: TodosState = {
todos: { tag: 'notAsked' },
};
```

Reducer transitions then replace the whole remote data value instead of
coordinating separate flags:

```ts
type TodosAction =
| { readonly type: 'FETCH_TODOS_REQUEST' }
| { readonly type: 'FETCH_TODOS_SUCCESS'; readonly payload: ReadonlyArray<Todo> }
| { readonly type: 'FETCH_TODOS_FAILURE'; readonly payload: string }
| { readonly type: 'FETCH_TODOS_RESET' };

const todosReducer = (
state: TodosState = initialState,
action: TodosAction,
): TodosState => {
switch (action.type) {
case 'FETCH_TODOS_REQUEST':
return { ...state, todos: { tag: 'loading' } };
case 'FETCH_TODOS_SUCCESS':
return { ...state, todos: { tag: 'success', data: action.payload } };
case 'FETCH_TODOS_FAILURE':
return { ...state, todos: { tag: 'failure', error: action.payload } };
case 'FETCH_TODOS_RESET':
return { ...state, todos: { tag: 'notAsked' } };
default:
return state;
}
};
```

Connected components can render each case with the same discriminant. The
`assertNever` branch turns a missing case into a type error when the union is
extended later.

```tsx
const TodoListView: React.FC<{ todos: RemoteData<ReadonlyArray<Todo>> }> = ({ todos }) => {
switch (todos.tag) {
case 'notAsked':
return <span>Choose a filter to load todos.</span>;
case 'loading':
return <span>Loading todos...</span>;
case 'failure':
return <span>{todos.error}</span>;
case 'success':
return <TodoList todos={todos.data} />;
default:
return assertNever(todos);
}
};

function assertNever(value: never): never {
throw new Error(`Unhandled remote data case: ${JSON.stringify(value)}`);
}
Comment on lines +1576 to +1578

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The assertNever helper is a generic TypeScript utility used to enforce exhaustiveness checks. Hardcoding the error message to 'Unhandled remote data case' makes it less reusable for other union types (such as actions, states, or action types) if readers copy-paste this helper into their projects. Making the error message generic improves its reusability.

Suggested change
function assertNever(value: never): never {
throw new Error(`Unhandled remote data case: ${JSON.stringify(value)}`);
}
function assertNever(value: never): never {
throw new Error('Unhandled case: ' + JSON.stringify(value));
}

```

[⇧ back to top](#table-of-contents)

### Typing reducer

> to understand following section make sure to learn about [Type Inference](https://www.typescriptlang.org/docs/handbook/type-inference.html), [Control flow analysis](https://github.com/Microsoft/TypeScript/wiki/What%27s-new-in-TypeScript#control-flow-based-type-analysis) and [Tagged union types](https://github.com/Microsoft/TypeScript/wiki/What%27s-new-in-TypeScript#tagged-union-types)
Expand Down
89 changes: 89 additions & 0 deletions README_SOURCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ I highly recommend to add a bounty to the issue that you're waiting for to incre
- [Action Creators 🌟](#action-creators-)
- [Reducers](#reducers)
- [State with Type-level Immutability](#state-with-type-level-immutability)
- [Modelling async data with ADT](#modelling-async-data-with-adt)
- [Typing reducer](#typing-reducer)
- [Typing reducer with `typesafe-actions`](#typing-reducer-with-typesafe-actions)
- [Testing reducer](#testing-reducer)
Expand Down Expand Up @@ -612,6 +613,94 @@ state.containerObject.numbers.push(1); // TS Error: cannot use mutator methods

[⇧ back to top](#table-of-contents)

### Modelling async data with ADT

Async reducer state is often written as several nullable fields and flags:

```ts
type TodosState = Readonly<{
isLoading: boolean,
error: string | null,
todos: ReadonlyArray<Todo> | null,
}>;
```

That shape accepts impossible combinations, for example `isLoading: true` with
both `error` and `todos` populated. A discriminated union makes every remote
data case explicit and keeps those combinations out of the state type.

```ts
type RemoteData<T, E = string> =
| { readonly tag: 'notAsked' }
| { readonly tag: 'loading' }
| { readonly tag: 'failure'; readonly error: E }
| { readonly tag: 'success'; readonly data: T };

type TodosState = Readonly<{
todos: RemoteData<ReadonlyArray<Todo>>,
}>;

const initialState: TodosState = {
todos: { tag: 'notAsked' },
};
```

Reducer transitions then replace the whole remote data value instead of
coordinating separate flags:

```ts
type TodosAction =
| { readonly type: 'FETCH_TODOS_REQUEST' }
| { readonly type: 'FETCH_TODOS_SUCCESS'; readonly payload: ReadonlyArray<Todo> }
| { readonly type: 'FETCH_TODOS_FAILURE'; readonly payload: string }
| { readonly type: 'FETCH_TODOS_RESET' };

const todosReducer = (
state: TodosState = initialState,
action: TodosAction,
): TodosState => {
switch (action.type) {
case 'FETCH_TODOS_REQUEST':
return { ...state, todos: { tag: 'loading' } };
case 'FETCH_TODOS_SUCCESS':
return { ...state, todos: { tag: 'success', data: action.payload } };
case 'FETCH_TODOS_FAILURE':
return { ...state, todos: { tag: 'failure', error: action.payload } };
case 'FETCH_TODOS_RESET':
return { ...state, todos: { tag: 'notAsked' } };
default:
return state;
}
};
```

Connected components can render each case with the same discriminant. The
`assertNever` branch turns a missing case into a type error when the union is
extended later.

```tsx
const TodoListView: React.FC<{ todos: RemoteData<ReadonlyArray<Todo>> }> = ({ todos }) => {
switch (todos.tag) {
case 'notAsked':
return <span>Choose a filter to load todos.</span>;
case 'loading':
return <span>Loading todos...</span>;
case 'failure':
return <span>{todos.error}</span>;
case 'success':
return <TodoList todos={todos.data} />;
default:
return assertNever(todos);
}
};

function assertNever(value: never): never {
throw new Error(`Unhandled remote data case: ${JSON.stringify(value)}`);
}
Comment on lines +697 to +699

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The assertNever helper is a generic TypeScript utility used to enforce exhaustiveness checks. Hardcoding the error message to 'Unhandled remote data case' makes it less reusable for other union types (such as actions, states, or action types) if readers copy-paste this helper into their projects. Making the error message generic improves its reusability.

Suggested change
function assertNever(value: never): never {
throw new Error(`Unhandled remote data case: ${JSON.stringify(value)}`);
}
function assertNever(value: never): never {
throw new Error('Unhandled case: ' + JSON.stringify(value));
}

```

[⇧ back to top](#table-of-contents)

### Typing reducer

> to understand following section make sure to learn about [Type Inference](https://www.typescriptlang.org/docs/handbook/type-inference.html), [Control flow analysis](https://github.com/Microsoft/TypeScript/wiki/What%27s-new-in-TypeScript#control-flow-based-type-analysis) and [Tagged union types](https://github.com/Microsoft/TypeScript/wiki/What%27s-new-in-TypeScript#tagged-union-types)
Expand Down