Published on

redux legacy에 타입 적용하기 - 2

redux legacy에 타입 적용하기

Overview

Introduction

저번 시간에는 redux legacy 환경에서 간단한 타입을 적용해봤다

이번에는 reducer에서 union action type을 처리해보자

⚠ redux 개발진은 union payload type을 권장하지 않는다

Objective

  • handleActions에서 union action type을 적용

Union Payload Type

저번 action 코드를 보자

src/features/todo/action.js

// @ts-check
import { createAction } from 'redux-actions';

/**
 * @typedef {import("./api").Todo} Todo
 */

export const SET_TODO = 'Todo/ADD_TODO';
export const setTodo = /** @type {typeof createAction<Todo>} */ (createAction)(SET_TODO);

여기에 Todo를 삭제하는 액션을 추가하자

export const REMOVE_TODO = 'Todo/REMOVE_TODO';
export const removeTodo = /** @type {typeof createAction<Pick<Todo, 'id'>['id']>} */ (createAction)(
  REMOVE_TODO
);

Pick<Todo, 'id'>['id']number

즉, removeTodopayloadnumber를 받는 액션이다

이 액션을 reducer에 추가하자

diff --git a/packages/redux-legacy-1/src/features/todo/reducer.js b/packages/redux-legacy-1/src/features/todo/reducer.js
index aae880b..a6d4c6e 100644
--- a/packages/redux-legacy-1/src/features/todo/reducer.js
+++ b/packages/redux-legacy-1/src/features/todo/reducer.js
@@ -1,6 +1,7 @@
 // @ts-check
 import { handleActions } from "redux-actions";
 import { SET_TODO } from "./action";
+import { REMOVE_TODO } from "./action";

 /**
  * @typedef {import("./api").Todo} Todo
@@ -24,6 +25,12 @@ export default handleTodoActions(
         [payload.id]: { ...payload },
       };
     },
+    [REMOVE_TODO]: (state, { payload }) => {
+      delete state[payload];
+      return {
+        ...state,
+      };
+    }
   },
   initialState,
 );

이렇게 하면 타입 에러가 발생한다

remove-todo-type-error

payloadTodo이기 때문이다

payload 타입에 removeTodopayload 타입을 추가하자

diff --git a/packages/redux-legacy-1/src/features/todo/reducer.js b/packages/redux-legacy-1/src/features/todo/reducer.js
index aae880b..a6d21a6 100644
--- a/packages/redux-legacy-1/src/features/todo/reducer.js
+++ b/packages/redux-legacy-1/src/features/todo/reducer.js

@@ -14,7 +15,7 @@ import { SET_TODO } from "./action";
 const initialState = {};

 const handleTodoActions =
-  /** @type {typeof handleActions<typeof initialState, Todo>} */ (handleActions);
+  /** @type {typeof handleActions<typeof initialState, Todo | Pick<Todo, 'id'>['id']>} */ (handleActions);

 export default handleTodoActions(
   {

이러면 reducer의 payload 타입이 Todo | number가 됐지만 또 다른 문제가 발생한다

set-todo-type-error-with-union

SET_TODO는 payload로 Todo만을 받아야 하는데 number도 받을 수 있어서 문제가 생겼다

이 두 타입 중 하나로 좁혀야 하는데 handleActions와 같은 구조에서는 그렇게 할 수 없다

Discriminated Union Type

타입 좁히기(Narrowing)를 이용하면 우리 의도대로 타입을 지정할 수 있는데

이걸 잘 쓰면 union type을 분리할 수 있다

관습적으로 redux 사용자들은 다음과 같이 reducer를 작성했다

/**
 * @typedef {|
 *   { type: 'Todo/SET_TODO'; payload: Todo; } |
 *   { type: 'Todo/REMOVE_TODO'; payload: Pick<Todo, 'id'>['id']; }
 * } TodoActions
 */

/**
 *
 * @param {InitialState} state
 * @param {TodoActions} action
 */
function reducer(state, { type, payload }) {
  switch (type) {
    case 'Todo/SET_TODO':
      return {
        ...state,
        [payload.id]: { ...payload },
      };
    case 'Todo/REMOVE_TODO':
      delete state[payload];
      return {
        ...state,
      };
    default:
      return state;
  }
}

이렇게 작성하면 각 case에 대해서 올바른 payload type을 얻을 수 있다

switch-case-set-todoswitch-case-remove-todo

개인적으로 switch-case로 reducer를 작성하는 것보다 handleActions와 같은 방식이 가독성이 좋다고 생각한다

그러나 handleActionsObject를 인자로 받을 뿐이기 때문에 type narrowing을 할 수 없다

이런 문제를 어떻게 해결할 수 있을까?

Type Assertion

때로는 우리가 TS보다 type을 잘 안다고 확신할 때가 있다

그럴 때 type assertion을 쓰면 된다

계획은 다음과 같다

  1. 기존 handleActions(reducerMap, initialState)와 같은 parameter를 가지는 새로운 함수 handleTodoActions 정의
  2. handleTodoActions에서 handleAction을 이용해 reducer 객체를 하나씩 만든다
  3. reducer들을 combineReducers로 합친다
  4. type assertion으로 합친 reducer의 type을 바꾼다
  5. 합친 reducer 반환

우선 handleTodoActions의 signature를 생각해보자

이 함수는 reducerMapinitialState를 인자로 받는다

reducerMap은 actionType과 각 actionType에 대한 reducer를 가지고 있어야 한다

/**
 * @template S
 * @typedef {{
 *  [SET_TODO]: (state: S, action: ReturnType<typeof setTodo>) => S;
 *  [REMOVE_TODO]: (state: S, action: ReturnType<typeof removeTodo>) => S;
 * }} TodoReducerMap
 */

그리고 return type은 기존 handleActions를 보면 된다

node_modules/@types/redux-actions/index.d.ts

export function handleActions<State, Payload>(
  reducerMap: ReducerMap<State, Payload>,
  initialState: State,
  options?: Options
): ReduxCompatibleReducer<State, Payload>;

return type은 ReduxCompatibleReducer<State, Payload>이다

따라서 handleTodoActions의 return type은 ReduxCompatibleReducer<State, Todo | Pick<Todo, 'id'>['id']>가 되어야 한다

그럼 코드로 구현해보자

/*
 * @template S
 * @param {TodoReducerMap<S>} reducerMap
 * @param {S} initialState
 */
const handleTodoActions = (reducerMap, initialState) => {
  const actionTypes = Object.keys(reducerMap);
  const reducers = actionTypes.reduce((acc, actionType) => {
    const reducer = reducerMap[actionType];
    acc[actionType] = handleAction(actionType, reducer, initialState);
    return acc;
  }, {});

  const ret = combineReducers(reducers);

  /**
   * @template T
   * @typedef {T[keyof T]} ValueOf
   */

  /**
   * @template A
   * @typedef {A extends Action<infer P> ? P : never} ExtractPayload
   */

  /**
   * @typedef {|
   *  ExtractPayload<Parameters<ValueOf<typeof reducerMap>>[1]>
   * } Payloads
   */

  return /** @type {ReduxCompatibleReducer<S, Payloads>} */ (ret);
};

마지막에 type assertion으로 union payload가 들어가도록 했다

union-payload

이제 payload의 타입이 제대로 잡힌다

type-assert-set-todotype-assert-remove-todo

combineReducers에서도 잘 나온다

src/features/reducers.js

reducers-union-type

store에도 잘 적용되었다

src/app/store.js

store-complete