Skip to content

Commit

Permalink
Added tassign, docs about typing reducers (#221)
Browse files Browse the repository at this point in the history
* Added tassign, docs about typing reducers

Fixes #216

* Linter fix

* Typo fix

* Corrections and clarifications

* Update strongly-typed-reducers.md

* Update strongly-typed-reducers.md

* unit tests tor tassign

* Code review change: split tassign into its own package.

[fixes #216]

* minor corrections
  • Loading branch information
SethDavenport authored Nov 7, 2016
1 parent b519883 commit 91c5eec
Show file tree
Hide file tree
Showing 6 changed files with 177 additions and 4 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,3 +154,4 @@ We also have a number of 'cookbooks' for specific Angular 2 topics:
* [Managing Side-Effects with redux-observable Epics](docs/epics.md)
* [Using the Redux DevTools Chrome Extension](docs/redux-dev-tools.md)
* [Ng2Redux and ImmutableJS](docs/immutable-js.md)
* [Strongy Typed Reducers](docs/strongly-typed-reducers.md)
166 changes: 166 additions & 0 deletions docs/strongly-typed-reducers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
# Strongly Typed Reducers

It's good practice in typescript to be as specific about your types as possible.
This helps you catch errors at compile-time instead of run-time.

Reducers are no exception to this rule. However it's not always obvious how to
make this happen in practice.

## Reducer Typing Best Practices

### Define an Interface for your State

It's important to strongly type the data in your store, and this is done by
defining types for the `state` arguments to your reducers:

```typescript
export type TFoo: string;

// Being explicit about the state argument and return types ensures that all your
// reducer's cases return the correct type.
export const fooReducer = (state: TFoo, action): TFoo => {
// ...
};

export interface IBar {
a: number;
b: string;
}

export const barReducer = (state: IBar, action): IBar => {
// ...
};
```

Since most applications are composed of several reducers, you should compose
a global 'AppState' by composing the reducer types:

```typescript
export interface IAppState {
foo?: TFoo;
bar?: IBar;
}

export const rootReducer = combineReducers({
foo: fooReducer,
bar: barReducer
});
```

This 'app state' is what you should use when injecting `NgRedux`:

```typescript
import { Injectable } from 'ng2-redux';
import { IAppState } from './store';

@Injectable()
export class MyActionService {
constructor(private ngRedux: NgRedux<IAppState>) {}

// ...
}
```

### Consider Using Built-In Types from Redux

Redux ships with a good set of official typings; consider using them. In
particular, consider importing and using the `Action` and `Reducer` types:

```typescript
import { Action, Reducer } from 'redux';

export const fooReducer: Reducer<TFoo> = (state: TFoo, action: Action): TFoo => {
// ...
};
```

Note that we supply this reducer's state type as a generic type parameter to `Reducer<T>`.

### Consider using 'Flux Standard Actions' (FSAs)

[FSA](https://github.com/acdlite/flux-standard-action/blob/master/src/index.js)
is a widely-used convention for defining the shape of actions. You can import
in into your project and use it:

```sh
npm install --save flux-standard-action @types/flux-standard-action
```

Flux standard actions take 'payload', and 'error' parameters in addition to the
basic `type`. Payloads in particular help you strengthen your reducers even
further:

```typescript
import { Reducer } from 'redux';
import { Action } from 'flux-standard-action';

export const fooReducer: Reducer<TFoo> = (state: TFoo, action: Action<TFoo>): TFoo => {
// ...
};
```

Here we're saying that the action's payload must have type TFoo.
If you need more flexibility in payload types, you can use a union and
[type assertions](https://www.typescriptlang.org/docs/handbook/advanced-types.html):

```typescript
export const barReducer: Reducer<IBar> = (state: IBar, action: Action<number | string>): IBar => {
switch(action.type) {
case A_HAS_CHANGED:
return Object.assign({}, state, {
a: <number>action.payload
});
case B_HAS_CHANGED:
return Object.assign({}, state, {
b: <string>action.payload
});
// ...
}
};
```

For more complex union-payload scenarios, Typescript's [type-guards](https://www.typescriptlang.org/docs/handbook/advanced-types.html) may also be helpful.

### Use a Typed Wrapper around Object.assign

In the Babel world, reducers often use `Object.assign` or property spread to
maintain immutability. This works in Typescript too, but it's not typesafe:

```typescript
export const barReducer: Reducer<IBar> = (state: IBar, action: Action<number | string>): IBar => {
switch(action.type) {
case A_HAS_CHANGED:
return Object.assign({}, state, {
a: <number>action.payload,
zzz: 'test' // We'd like this to generate a compile error, but it doesn't
});
// ...
}
};
```

Ideally, we'd like this code to fail because `zzz` is not a property of the state.
However, the built-in type definitions for `Object.assign` return an intersection
type, making this legal. This makes sense for general usage of `Object.assign`,
but it's not what we want in a reducer.

Instead, we've provided a type-corrected immutable assignment function, [`tassign`](https://npmjs.com/package/tassign),
that will catch this type of error:

```typescript
import { tassign } from 'tassign';

export const barReducer: Reducer<IBar> = (state: IBar, action: Action<number | string>): IBar => {
switch(action.type) {
case A_HAS_CHANGED:
return tassign(state, {
a: <number>action.payload,
zzz: 'test' // Error: zzz is not a property of IBar
});
// ...
}
};
```

Following these tips to strengthen your reducer typings will go a long way
towards more robust code.
3 changes: 2 additions & 1 deletion examples/counter/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,11 @@
"core-js": "^2.3.0",
"ng2-redux": "^4.0.0-beta.7",
"redux": "^3.5.0",
"redux-logger": "^2.6.1",
"redux-localstorage": "^0.4.0",
"redux-logger": "^2.6.1",
"reflect-metadata": "0.1.3",
"rxjs": "5.0.0-beta.12",
"tassign": "^1.0.0",
"zone.js": "^0.6.21"
},
"devDependencies": {
Expand Down
5 changes: 3 additions & 2 deletions examples/counter/store/search.reducer.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { tassign } from 'tassign';
import { SEARCH_ACTIONS } from '../actions/search.actions';

export interface ISearchState {
Expand All @@ -18,14 +19,14 @@ export function searchReducer(

switch (action.type) {
case SEARCH_ACTIONS.SEARCH:
return Object.assign({}, state, {
return tassign(state, {
onSearch: true,
keyword: action.payload,
total: state.total
});
case SEARCH_ACTIONS.SEARCH_RESULT:
let total = action.payload.total;
return Object.assign({}, state, {
return tassign(state, {
onSearch: state.onSearch,
keyword: state.keyword,
total
Expand Down
1 change: 0 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { NgRedux } from './components/ng-redux';
import { DevToolsExtension } from './components/dev-tools';
import { select } from './decorators/select';
import { NgReduxModule } from './ng-redux.module';


export {
NgRedux,
Expand Down
5 changes: 5 additions & 0 deletions src/utils/shallow-equal.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,5 +53,10 @@ describe('Utils', () => {
)
).to.equal(false);
});

it('should return true for two references to the same thing', () => {
const thing = { a: 1, b: 2, c: undefined };
expect(shallowEqual(thing, thing)).to.equal(true);
});
});
});

0 comments on commit 91c5eec

Please sign in to comment.