About halfway through last year, I started working with React and Redux. Our client app is extremely busy, full of data streaming in, going back out, and being passed around. Since I am the only full-time engineer and on a tight schedule, I moved to TypeScript in an effort to get something to look over my shoulder while I worked. The migration was not pleasant but the learning curve was practically non-existant and the benefits were immediately clear.

What was not clear immediately was how I could get the most of out TypeScript. TypeScript’s type inference is one of its greatest strenghts but it can bite you in the ass because it will allow you to treat everything as a generic any. This might be handy sometimes but in general, you do yourself a disservice when you choose to leave objects untyped when stricter options exist.

Nowhere is this more evident and troublesome than when dealing with the output of reducers and keys from the Redux store. Left alone, state keys mapped to props will behave as any. If there is one area of an app that I think strong types are crucial, it is the state that is shared throughout your app. In a perfect world, at a minimum, I want the following guarantees and behavior:

  • Each key of my state has known, predictable values
  • If the output of a reducer changes, state keys that depend on old output will become immediately apparent
  • If the shape of my state changes, invalid reliance upon old truths will become immediately apparent
  • The definition of my state’s shape should be centrally managed, I should not have to cast types in components

It took a little work and thought but I ended up with an approach that achieves all of this. It requires a little more boilerplate than you might find appealing but it is definitely worth the clarity, stability, and refactoring oversight that it provides.

Given the following:

// A reducer
function crucialObject(currentState = { firstKey: 'none', secondKey: 'none' }, action) {
  switch (action.type) {
    case 'FIRST_VALUE': {
      return { firstKey: 'new', secondKey: action.newValue };
    }
    case 'SECOND_VALUE': {
      return Object.assign({}, currentState, { secondKey: action.newValue });
    }
    default:
      return currentState;
  }
}

// A root reducer that will be fed to a store

const rootReducer = combineReducers({ crucialObject });

// A container/component with a mapStateToProps function that will be used with connect

const mapStateToProps = (state) => {
  return { crucialObject: state.crucialObject };
};

// And, in that same component, an expectation of what keys will exist on crucialObject
class MyClass extends Component<any, any> {
  render() {
    const myVal = this.props.crucialObject.firstKey;
  }
}

We want a healthy dependency between these pieces of code. If the reducer starts spitting out objects that don’t match what our component expects, we need to know! We can accomplish this by defining a few interfaces and then wiring them together carefully.

First, the reducer. We need to define the shape out of its output, which is simple enough.

interface CrucialObject {
  firstKey: string;
  secondKey: string;
}

function crucialObject(currentState = { firstKey: 'none', secondKey: 'none' }, action) : CrucialObject {
  // the rest is unchanged
}

Next, we want to tell subscribers of the store that if they call upon state.crucialObject, they will get a CrucialObject. We can do this with another interface. I tend to define this in the same file as my reducer.

interface CrucialValueReducer {
  crucialObject: CrucialObject
}

We need to define all of the keys and values that exist on our root reducer. Easy again! In the same file where I define rootReducer, I also define a State interface. This interface extends the interfaces that are provided by my reducer files.

interface State extends CrucialValueReducer {};

// we don't need to do anything with this here, but I find it is good practice to keep these two together since a change to one will require a change to the other
const rootReducer = combineReducers({ crucialObject });

Next, in my component, I need to tell the compiler what it should expect of the state parameter in mapStateToProps.

const mapStateToProps = (state: State) => {
  return { crucialObject: state.crucialObject };
};

This is a great change. Now, the compiler knows that State contains the combined interfaces of all of my reducers. If I change the name of my state key, remove it entirely, or change its output, mapStateToProps will bark at me and I will have to fix them. A good example would be to start with this:

const mapStateToProps = (state: State) => {
  return { safeToProceed: state.crucialObject.secondKey === 'new' };
};

If, in my reducer, I get rid of secondKey, the function that the above code is invalid. Awesome!

We have two things left. First, we want to guarantee that mapStateToProps returns an object with the complete shape that our component needs. This is no problem with, you guessed it, another interface.

interface ComponentStateProps {
  crucialView: CrucialObject;
}

const mapStateToProps = (state: State) : ComponentStateProps => {
  return { crucialObject: state.crucialObject };
};

This is crucial. If we omit it, we might make a change that the compiler sees as valid that our component is not expecting. As written, we’re in good shape. If we do the code below, though, the output of our state will not match the promise we will momentarily make to our component.

interface ComponentStateProps {
  crucialView: CrucialObject;
}

// The compiler will catch this because our object does not match the return signature
const mapStateToProps = (state: State) : ComponentStateProps => {
  return { crucialObject: state.crucialObject.firstKey };
};

Finally, we can reuse the ComponentStateProps so references within the body of the component can be matched to our interface.

class MyClass extends Component<ComponentStateProps, any> {
  // The body is unchanged, but if we call upon `this.props`, we'll see the injected state keys.
  // If we changed our State Props interface, dependencies in here will become immediately clear
}

And there we have it: dependencies are clearly defined and will be hightlighted by the compiler if broken. If we need to add another reducer, we can continue extending our State interface:

interface State extends CrucialValueReducer, CurrentUserReducer {};

We are now covered from multiple angles.

  • By identifying an object as a State, I know what data is available.
  • By matching the output of mapStateToProps to my component’s input, I can be confident that the state will move safely from Redux to the view

I find it weird that in all my reading about TypeScript and Redux, I could find dozens of examples of adding types fo reducer inputs but almost nothing about outputs and the sanctity of state. Maybe there’s a simpler way of handling it? If so, I’d love to know. Regardless, TypeScript is a joy to work with and this helps get more out of it in a busy app. This is one case where you have to give it a few hints up front, but then it will keep its eyes open for you forever.