리듀서 리팩토링 덕분에 겪은 타입 삽질 여정기

mosad2334·2022년 10월 6일
0

사건의 발단 : 깃헙 마켓플레이스 처음 구경하다가 codeFactor 한번 써보고 싶어서 궁금해서 바로 해봤다.

codefactor 曰: 당신의 reducer.tsx가 complex하다
나: 뭐야 원래 리듀서 다 이렇게 쓰던데

하지만 codeFactor한테 칭찬받고 싶으니 리팩토링 시작.

아래의 코드들은 기존 프로젝트 코드에서 모두 축소화 되었다.

기존의 reducer.tsx

const reducer = (state: stateProps, action: actionProps): stateProps => {
  switch (action.type) {
    case "COUNT_UP":
      if (state.count >= COUNT_MAX) return state;
      return { ...makeInitialState(state.count + 1) };
    case "STRIKE":
      const newStrike = [...state.strike];
      newStrike[action.location] = action.character;
      return {
        ...state,
        strike: newStrike,
        count: fixRequireCount({
          count: state.count,
          ball: state.ball,
          strike: newStrike,
        }),
      };
  }
};

export default reducer;

하긴 리팩토링에서도 switch 문은 웬만하면 쓰지말라고 쓰여져 있던 것 보고 놀랐던 기억이 있다. 보통 이런 상황에선 class를 추가해서 다형성을 주던데 리듀서에서는 그렇게 하기 좋은건지 모르겠고 애매하다고 생각함.
switch문 대신에 레코드 오브젝트로 type을 key로 받아서 해당하는 함수를 뽑아내는 reduceFunctions이라는 사전을 만들어서 쓰면 되겠지라고 가볍게 생각.

types.d.ts

type countUpActionProps = { type: "COUNT_UP" };
type strikeActionProps = {
  type: "STRIKE";
  location: number;
  character: string;
};


type typeActionProps = {
  COUNT_UP: countUpActionProps;
  STRIKE: strikeActionProps;
};

type actionProps = typeActionProps[keyof typeActionProps];

reducer.tsx

function countUp({
  state,
}: {
  state: stateProps;
  action: typeActionProps["COUNT_UP"];
}): stateProps {
  if (state.count >= COUNT_MAX) return state;
  return { ...makeInitialState(state.count + 1) };
}


function strike({
  state,
  action,
}: {
  state: stateProps;
  action: typeActionProps["STIRIKE"];
}): stateProps {
  if (action?.location === undefined || action?.character === undefined)
    return state;
  const newStrike = [...state.strike];
  newStrike[action.location] = action.character;
  return {
    ...state,
    strike: newStrike,
    count: fixRequireCount({
      count: state.count,
      ball: state.ball,
      strike: newStrike,
    }),
  };
}

const reduceFunctions: Record<
  actionProps["type"],
  ({ state, action }: { state: stateProps; action: actionProps }) => stateProps
> = {
  COUNT_UP: countUp,
  STRIKE: strike,
};

function reducer(state: stateProps, action: actionProps): stateProps {
  return reduceFunctions[action.type]({
    state,
    action,
  });
}

export default reducer;

다음과 같은 에러가 난다.

Type '({ state, action, }: { state: stateProps; action: strikeActionProps; }) => stateProps' is not assignable to type '({ state, action }: { state: stateProps; action: actionProps; }) => stateProps'.
Types of parameters '__0' and '__0' are incompatible.

하지만 actionProps은 strikeActionProps가 될수 있는데도요? 라고 항의 하고싶지만 reduceFunctions는 action.type로 자신의 키값을 정해서 해당하는 값으로 나가는지 모르기 때문에 strikeActionProps로 특정하지 못하므로 먹히지 않았다.
reduceFunctions 스스로 action.type이 키값 이니까 그에 맞는 action 인수의 Props값으로 지정해 줄 수 있다고 알려줄 수 있는 방법이 있을지 현재까진 모르겠다. 그냥 reduceFunctions를 사용하는 구간에서 switch 문이나 if문으로 action.type을 특정하는 컨텍스트를 열어서 사용하는 방법밖에는 없어 보였는데, 근데 일단 switch를 사용하지 않으려고 이 짓을 하고 있기 때문에 패스.

다음은 오류가 생기지 않은 첫번째 완성.

type fight round1

reducer.tsx


type reduceFunctionProps = {
  state: stateProps;
  action: {
    type: actionProps["type"];
    location?:number;
    character?:string;
  };
};


function countUp({ state }: reduceFunctionProps): stateProps {
  if (state.count >= COUNT_MAX) return state;
  return { ...makeInitialState(state.count + 1) };
}

function strike({ state, action }: reduceFunctionProps): stateProps {
  if (action?.location === undefined || action?.character === undefined)
    return state;
  const newStrike = [...state.strike];
  newStrike[action.location] = action.character;
  return {
    ...state,
    strike: newStrike,
    count: fixRequireCount({
      count: state.count,
      ball: state.ball,
      strike: newStrike,
    }),
  };
}

reduceFunctionProps 의 action에 모든 actionProps가 사용할 수 있는 키값을 포함할 수도 하지않을수도 있게 해주기 전략으로 갔다.
actionProps 종류가 추가될 때마다 수정할 일이 생길 수도 있는 점이 싫어서 한번에 다 불러오기 전략을 찾아봤다.

type reduceFunctionProps = {
  state: stateProps;
  action: Partial<typeActionProps["STRIKE"]>;
};

오. 이렇게 하면 strike function에서 action의 location과 chracter의 접근 가능하다. Partial을 계속 늘려주면 이런식으로

type reduceFunctionProps = {
  state: stateProps;
  action: Partial<typeActionProps["STRIKE"]> &
    Partial<typeActionProps["COUNT_UP"]> ;
};

이걸 한번에 쓰면 이건가

type reduceFunctionProps = {
  state: stateProps;
  action: Partial<typeActionProps[keyof typeActionProps]>;
};

(property) action: Partial<countUpActionProps | strikeActionProps >

아님.
keyof로 Union 모양으로 가져왔는데, Intersection으로 전부 바꿔주면 되지 않을까 해서 다음 유틸 필살기를 가져옴.

type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
  k: infer I
) => void
  ? I
  : never;

type reduceFunctionProps = {
  state: stateProps;
  action: UnionToIntersection<Partial<typeActionProps[keyof typeActionProps]>>;
};

(property) action: Partial<countUpActionProps> & Partial<strikeActionProps>

이제 Partial들의 intersection으로 가져와진다.

UnionToIntersection은 https://github.com/microsoft/TypeScript/issues/40448 에서 가져옴.

UnionToIntersection 코드 이해해보려고
https://driip.me/b812974b-3974-46e3-829e-1476b9b30c94 정독이 도움됨.

reducer.tsx

function reducer(state: stateProps, action: actionProps): stateProps {
  return reduceFunctions[action.type]({
    state,
    action, // action에서 에러
  });
}

Type 'actionProps' is not assignable to type 'Partial<countUpActionProps > & Partial<strikeActionProps>.
Type 'countUpActionProps' is not assignable to type 'Partial<countUpActionProps> & Partial<strikeActionProps> .
Type 'countUpActionProps' is not assignable to type 'Partial<strikeActionProps>'.
Types of property 'type' are incompatible.
Type '"COUNT_UP"' is not assignable to type '"STRIKE"'.

reduceFunction의 함수들에겐 문제가 없는데 이제 reducer 함수의 actionProps와 안맞는당..

이쯤에서 다시 정독하고옴
http://blog.hwahae.co.kr/all/tech/tech-tech/9954/

type reduceFunctionProps = {
  state: stateProps;
  action: countUpActionProps & strikeActionProps;
};

(property) action: never

합쳐질거라고 생각했던 게 왜 안 합쳐질까 생각한 것이 type이 각자 다른 상수를 가지고 있기 때문에 교집합이 생길 수가 없다고 추리돼서 실제로 type을 같은 상수로 맞춰봤더니

(property) action: countUpActionProps & strikeActionProps

로 잘 나온다.

그럼 actionProps의 집합들에게 각자 다른 상수를 가지고 있기 때문에 문제되는 type만 뺏어버리면 합쳐질 것이라고 생각함.

type fight round2


type DistributiveOmit<T, K extends keyof any> = T extends any
  ? Omit<T, K>
  : never;

type reduceFunctionProps = {
  state: stateProps;
  action: UnionToIntersection<Partial<DistributiveOmit<typeActionProps[keyof typeActionProps], "type">>>;
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const hi: reduceFunctionProps["action"]= {
  // type: "hi", 오류
  location: 3, 
  // location: "33", location:number임 오류
  character: "0",
  // character: 0, chracter:string임 오류
};

유니온타입의 각 집합들에게 Omit 모듈 돌리는건 https://stackoverflow.com/questions/57103834/typescript-omit-a-property-from-all-interfaces-in-a-union-but-keep-the-union-s 참고함.

오류도 잘 뜨고 원하는대로 잘 돌아감.

원하는 곳으로 멀리멀리 돌아오긴 했는데

  1. action 인수를 countUp 함수 같이 모든 reduceFunctions의 함수들이 쓰지 않는 점도 그렇고,
  2. action의 타입이 너무 덕지덕지 더러운 것도 보기 힘들고,
  3. reducer 함수와 인수들 타입이 웬만할 때 동일했으면 좋겠다고 생각해서

여기까지 와서 action 타입을 그냥 actionProps로 해버리고 싶었음.

type fight round3


type reduceFunctionProps = {
  state: stateProps;
  action: actionProps;
};


function strike({ state, action }: reduceFunctionProps): stateProps {
  // - if (action?.location === undefined || action?.character === undefined)
  if (action.type !== "STRIKE") return state;
  const newStrike = [...state.strike];
  newStrike[action.location] = action.character;
  return {
    ...state,
    strike: newStrike,
    count: fixRequireCount({
      count: state.count,
      ball: state.ball,
      strike: newStrike,
    }),
  };
}

함수 내에서 if문으로 action.type을 특정하는 것으로 타입 에러를 피하게 했는데, reduceFunctions를 보지 않고도 이게 더 reduceFunctions 내의 함수들의 본래 의도가 명확하게 보이고 훨씬 나아보였음. 난 왜 감히 round1 전에 switch 안쓰니까 if도 안써야징 ㅎㅎ 라고 생각을 했던 것인지?


function countUp(state: stateProps, action: actionProps): stateProps {
  if (action.type !== "COUNT_UP") return state;
  if (state.count >= COUNT_MAX) return state;
  return { ...makeInitialState(state.count + 1) };
}

function strike(state: stateProps, action: actionProps): stateProps {
  if (action.type !== "STRIKE") return state;
  const newStrike = [...state.strike];
  newStrike[action.location] = action.character;
  return {
    ...state,
    strike: newStrike,
    count: fixRequireCount({
      count: state.count,
      ball: state.ball,
      strike: newStrike,
    }),
  };
}


const reduceFunctions: Record<
  actionProps["type"],
  (state: stateProps, action: actionProps) => stateProps
> = {
  COUNT_UP: countUp,
  STRIKE: strike,
};

function reducer(state: stateProps, action: actionProps): stateProps {
  return reduceFunctions[action.type](state, action);
}

훨씬 만족스러운데, 이 쉬운 길을 놔두고 대체 난 왜 이 삽질을 했는가에 대한 허망한 마음은 감출 수 없었다.

그래도 덕분에 타스의 집합에 대한 경험치를 얻은 기념으로 기록해둔다.

GitHub reducer.tsx
type fight round 1
type fight round 2
end of reducer refactoring

profile
Web Developer / Read "KEEP CALM CARRY ON" Poster

0개의 댓글