본문 바로가기
웹 프로그래밍/👨‍👨‍👧‍👧소셜 가계부

[React/Node.js/Express/MongoDB] 소셜 가계부 프로젝트 구현 일지: JavaScript → TypeScript 마이그레이션 4(가계부 페이지 관련 컴포넌트: TransactionList, Modal)

by 청량리 물냉면 2024. 4. 8.
반응형

이전 마이그레이션 관련 포스팅

  1. JavaScript → TypeScript 마이그레이션 1(세팅, App, AppRouter)
  2. JavaScript → TypeScript 마이그레이션 2(대시보드 페이지, 대시보드 관련 컴포넌트: state 빈 배열 초기화 never type 에러 해결)
  3. JavaScript → TypeScript 마이그레이션 3(가계부 페이지, Button 컴포넌트: onClick 함수 타입 지정, 자잘한 오류들 해결)

 

 

저번 포스팅에 이어 이번에도 가계부 관련 페이지 코드를 TypeScript로 변경해 볼 예정이다.

기존 코드 확장자를 jsx → tsx로 변경한 뒤 오류가 생기는 부분이나 흰 점선이 나타나며 타입 지정하라고 지정해 주는 부분 위주로 변경해 보겠다.

변경 전 코드는 아래와 같다. 날짜별로 필터링된 데이터를 부모 컴포넌트에서 props로 받아 Table 형식으로 화면에 뿌려주는 컴포넌트이다.

// TransactionList.jsx

import React, { useState } from "react";
import styled from "styled-components";
...

export default function TransactionList({ data }) {
  const dispatch = useDispatch();
  const token = useSelector((state) => state.user.token);
  const [selectedData, setSelectedData] = useState("");
  const [openEditor, setOpenEditor] = useState(false);
  const [isEdit, setIsEdit] = useState(false);
  const [sortType, setSortType] = useState("latest");

  const handleEdit = (id) => {
    setIsEdit(true);
    setOpenEditor(true);

    const item = data.find((transaction) => transaction._id === id);

    const selected = {
      id: item._id,
      transaction_type: item.transaction_type,
      date: item.date,
      category: item.category,
      title: item.title,
      amount: item.amount,
      memo: item.memo,
    };

    setSelectedData(selected);
  };

  function handleCancelEditor() {
    setOpenEditor(false);
    setIsEdit(false);
  }

  const handleRemove = async (id) => {
    setOpenEditor(false);
    if (window.confirm("내역을 삭제하시겠습니까?")) {
      try {
        await deleteTransactionAPI(id, token);
        dispatch(removeTransaction(id));
        alert("삭제 완료!");
      } catch (err) {
        alert("가계부 내역 삭제 도중 오류가 발생했습니다.", err.message);
      }
    }
  };

  const getSortedTransactionList = () => {
    const compare = (a, b) => {
      if (sortType === "latest") {
        return parseInt(b.date) - parseInt(a.date);
      } else {
        return parseInt(a.date) - parseInt(b.date);
      }
    };

    const copyList = JSON.parse(JSON.stringify(data));
    const sortedList = copyList.sort(compare);

    return sortedList;
  };

  return (
    <Section>
      <div className="main-title">
        <h2>입출금 내역</h2>
        <CiSquarePlus onClick={() => setOpenEditor(true)} />
        {openEditor && (
          <Modal visible={openEditor} closable={true} maskClosable={false} onClose={handleCancelEditor}>
            <TransactionEditor isEdit={isEdit} selectedData={selectedData} closeEditor={handleCancelEditor} />
          </Modal>
        )}
      </div>
      <ControlOption value={sortType} chooseOption={setSortType} optionList={sortOption} />
      <div className="history">
        {getSortedTransactionList().map((item) => (
          <div className="card" key={item._id}>
            <div className="content">
              <div className="tr-data" onClick={() => handleEdit(item._id)}>
                <div className="cell date">
                  <b>{`${new Date(item.date).getDate()}일 (${day[new Date(item.date).getDay()]})`}</b>
                </div>
                <div className="cell category-title">
                  <div className="category">{item.category}</div>
                  <div className="title">{item.title}</div>
                </div>
                <div className="cell memo">{item.memo}</div>
                <div className="cell amount" style={{ color: item.transaction_type === false ? "#ec444c" : "green" }}>
                  {item.transaction_type === false
                    ? "-" + item.amount.toLocaleString("ko-KR")
                    : item.amount.toLocaleString("ko-KR")}
                </div>
              </div>

              <div className="cell action">
                <FaTrashAlt onClick={() => handleRemove(item._id)} />
              </div>
            </div>
          </div>
        ))}
      </div>
    </Section>
  );
}

const Section = styled.section`
  ...
`;

 

 

🚫 Expected 0-1 arguments, but got 2.ts(2554)

alert("가계부 내역 삭제 도중 오류가 발생했습니다.", err.message);

위 코드에서 발생한 오류이다.

인수가 0-1개 들어가야 하는데 2개 들어갔다고 한다. 

 

✅ 해결

alert("가계부 내역 삭제 도중 오류가 발생했습니다." + err.message);

`+` 기호를 사용해 문자열 2개를 하나로 만들어 alert 내부에 전달해 주었다. 

 

 

🚫 Property 'date' does not exist on type 'number'

  const getSortedTransactionList = () => {
    //    const compare = (a, b) => { 타입 명시 전
    const compare = (a: number, b: number) => {
      if (sortType === "latest") {
        return b.date - a.date;
      } else {
        return a.date - b.date;
      }
    };

    const copyList = JSON.parse(JSON.stringify(data));
    const sortedList = copyList.sort(compare);

    return sortedList;
  };

date를 기준으로 데이터를 오름차순/내림차순 정렬해 주는 함수이다.

이때 date로는 `getTime()`을 이용해 반환한 숫자 값을 사용하므로 지정해 줄 타입은 number이다.

따라서 매개변수 a, b에 각각 number 타입을 지정해 주었는데, number 타입에  date 속성이 존재하지 않는다는 에러가 떴다.

 

✅ 해결

자세히 보니 `a.date`, `b.date`로 a와 b의 값에서 date를 가져와 값을 정렬하고 있는 상황이었다.

따라서 리턴 타입을 number로 지정해 준 뒤 함수 호출부를 살펴보았다. (리턴 타입은 따로 명시해 주지 않아도 TypeScript가 알아서 타입 추론해서 결정하지만 리턴 타입을 조금 더 명확하게 하기 위해 적어주었다) 

getSortedTransactionList().map((item: TransactionData) => (...))

item의 타입은 `TransactionData`(가계부의 모든 프로퍼티의 형식을 지정해 놓은 인터페이스)이다.

TransactionData

 

`getSortedTransactionList()` 함수 호출을 통해 내림차순/오름차순 정렬된 가계부 리스트가 반환되고, map을 이용해 리스트를 순회하며 데이터를 Table에 넣어주는 방식으로 코드가 실행된다.

`getSortedTransactionList()` 함수 내부에서는 `const sortedList = copyList.sort(compare);` 코드를 통해 실질적인 정렬이 이루어지고 있다.

이때 정렬할 리스트의 데이터 타입은 당연히 `TransactionData` 타입이다. 따라서 `compare()` 함수에서 받는 데이터는 `TransactionData` 타입이라는 것을 알 수 있다.

따라서 아래와 같이 타입을 지정해주니 오류가 사라졌다.

    const compare = (a: TransactionData, b: TransactionData): number => {
      if (sortType === "latest") {
        return b.date - a.date;
      } else {
        return a.date - b.date;
      }
    };

TransactionData 인터페이스에서 date는 number 타입으로 지정되어 있기 때문에 더 이상 `parseInt()`를 사용할 수 없고 사용할 필요도 없다. `parseInt()` 사용 코드까지 삭제하니 모든 오류가 해결되었다.

 

 

🚫 Argument of type 'string | undefined' is not assignable to parameter of type 'string'. Type 'undefined' is not assignable to type 'string'

아래 코드에서 에러가 발생하고 있었다.

<FaTrashAlt onClick={() => handleRemove(item._id)} />

 

`handleRemove` 함수

  const handleRemove = async (id: string) => {
    ...
  };

 

✅ 해결

에러 메시지를 해석하면 

'string | undefined' 형식의 인수를 'string' 형식의 매개 변수에 할당할 수 없습니다.
'정의되지 않음' 유형을 'string' 유형에 할당할 수 없습니다

이다.

즉, `handleRemove`함수는 string 타입의 id를 받는데 실제 호출부인 `handleRemove(item._id)`에서는 undefined 값을 전달하고 있다는 뜻이다. 정말로 그런지 `TransactionData` 인터페이스 내부를 살펴보았다.

`_id` 위에 마우스를 올리니 저렇게 위에 창이 하나 뜨며 프로퍼티의 타입을 알려준다.

프로퍼티를 선택적 프로퍼티로 지정 시 string 외에도 undefined 타입이 지정될 수 있다는 걸 처음 알았다; 사용자가 프로퍼티 타입을 지정하지 않으면 타입스크립트가 개발자 대신 어떤 타입으로든 그 프로퍼티의 값을 지정할 텐데, 그걸 간과하고 있었다. 그리고 지정되는 값이 undefined라는 것도 오늘 처음 알았다.

위의 오류 해결 방법은 간단하다. undefined 값이 들어갈 수 없도록 `_id`의 타입을 string으로 못 박아 두었다.

export interface TransactionData {
  id?: string;
  // _id?: string; 선택적 프로퍼티 => 필수로 변경 
  _id: string;
  ...
}

해결 완료.

 

 

🚫 Property 'className' is missing in type '{ children: Element; visible: true; maskClosable: boolean; onClose: () => void; }' but required in type '{ className: any; onClose: any; maskClosable: any; visible: any; children: any; }'

          <Modal visible={openEditor} maskClosable={false} onClose={handleCancelEditor}>
            <TransactionEditor isEdit={isEdit} selectedData={selectedData} closeEditor={handleCancelEditor} />
          </Modal>

모달 호출 부분에서 오류가 발생하고 있다. 

바로 ` Modal` 컴포넌트로 달려가서 Modal 내부 모든 프로퍼티의 타입을 지정해 준다.

// type
type ModalProps = {
  className?: string;
  onClose: MouseEventHandler<HTMLDivElement>;
  maskClosable?: boolean;
  visible: boolean;
  children: ReactNode;
};

// interface, 객체는 interface 권장
interface ModalProps {
  className?: string;
  onClose: MouseEventHandler<HTMLDivElement>;
  maskClosable?: boolean;
  visible: boolean;
  children: ReactNode;
};

선택적 프로퍼티로 지정할 부분은 `?` 기호로 지정해 준다.

원래 interface로 객체 타입을 정해 주었는데 이번에는 type 키워드도 사용해 보았다.

type vs interface

type은 모든 타입을 선언할 때 사용 가능하며, interface는 객체에 대한 타입을 선언할 때만 사용할 수 있다.
또한 확장 불가능한 타입을 선언하고 싶을 때는 type을, 확장 가능한 타입을 선언하고 싶을 때는 interface를 사용하면 된다.
타입 객체의 확장성을 위해서 객체 선언 시에는 interface를 사용하는 것이 더 좋다.  반면 단순한 원시값(Primitive Type)이나 튜플(Tuple), 유니언(Union) 타입을 선언할 때 type을 사용하는 것이 좋다.
참고

 

마지막으로 아래와 같이 props 옆에 `:`을 사용해 타입을 명시해 주면 된다.

// Modal.tsx

type ModalProps = {
  className?: string;
  onClose: MouseEventHandler<HTMLDivElement>;
  maskClosable?: boolean;
  visible: boolean;
  children: ReactNode;
};

function Modal({ className, onClose, maskClosable, visible, children }: ModalProps) {
  const onMaskClick = (e) => {
    if (maskClosable && e.target === e.currentTarget) {
      onClose(e);
    }
  };

  return (...)
}

 

👇 참고한 글 

 

[TypeScript] 리액트 children 타입 지정해주기 - 타입별 특징

Children Props? 리액트 모든 컴포넌트에서 children props를 사용할 수 있습니다. children props란 컴포넌트의 여는 태그와 닫는 태그 사이의 내용입니다. 예를 들면, function App() { return Hello world! } 이 태그

shape-coding.tistory.com

 

What is the type of the 'children' prop?

I have a very simple functional component as follows: import * as React from 'react'; export interface AuxProps { children: React.ReactNode } const aux = (props: AuxProps) => props.chi...

stackoverflow.com

 

 

🚫 Parameter 'e' implicitly has an 'any' type React TypeScript

 const onMaskClick = (e) => {
    if (maskClosable && e.target === e.currentTarget) {
      onClose(e);
    }

e: 의 타입을 정의하라는 말인 것 같다. 

e의 타입은 어떻게 정하지...?

인터넷을 뒤져봤더니 아래 글이 나온다.

https://www.reddit.com/r/reactjs/comments/1034qsv/trying_to_figure_out_the_type_for_a_click_event/

저런 식으로 지정하는구나 싶어서 별 생각 없이 따라 해 보았다.

  const onMaskClick = (e: MouseEvent<HTMLButtonElement, MouseEvent>): void => {
    if (maskClosable && e.target === e.currentTarget) {
      onClose(e);
    }
  };

당연히 발생하는 에러😂

Argument of type 'MouseEvent<HTMLButtonElement, MouseEvent<Element, MouseEvent>>' is not assignable to parameter of type 'MouseEvent<HTMLDivElement, MouseEvent>'.
Property 'align' is missing in type 'HTMLButtonElement' but required in type 'HTMLDivElement'.

  const onMaskClick = (e: MouseEvent<HTMLElement, MouseEvent>): void => {
    if (maskClosable && e.target === e.currentTarget) {
      onClose(e);
    }
  };

Argument of type 'MouseEvent<HTMLElement, MouseEvent<Element, MouseEvent>>' is not assignable to parameter of type 'MouseEvent<HTMLDivElement, MouseEvent>'.
Property 'align' is missing in type 'HTMLElement' but required in type 'HTMLDivElement'.ts(2345)

IDE가 추천해 준 대로 타입을 지정해 주었는데도 위와 같이 에러가 발생한다.

지금 보니 에러 메시지가 살짝 안 맞는 것 같다. 코드를 수정 후 save 했는데도 반영이 느린 건지 IDE에서 보여주는 에러가 이전과 변함없이 그대로인 적이 종종 있는데 그래서가아닌지...

어쨌든 열심히 서치 해서 아래와 같이 코드를 고쳤다.

type ModalProps = {
  className?: string;
  onClose: (e: React.MouseEvent<HTMLElement, MouseEvent>) => void;
  maskClosable?: boolean;
  visible: boolean;
  children: ReactNode;
};

function Modal({ className, onClose, maskClosable, visible, children }: ModalProps) {
  const onMaskClick = (e: MouseEvent<HTMLElement, MouseEvent>): void => {
    if (maskClosable && e.target === e.currentTarget) {
      onClose(e);
    }
  };

  return (
   ...
  );
}

무슨 원리인지는 모르겠지만 `onClose`의 타입을 변경해 줌으로써 해결완료.

이 부분에서 시간을 너무 많이 소요해서 우선 적용부터 해보고 그 후에 원리를 파악하자@ 하는 생각이었는데...

또다시 오류가 발생했다.

 

 

🚫 No overload matches this call. Overload 1 of 2, '(props: { slot?: string | undefined; style?: CSSProperties | undefined; title?: string | undefined; className?: string | undefined; onClose: (e: MouseEvent<HTMLElement, MouseEvent<...>>) => void | null; ... 262 more ...; onTransitionEndCapture?: TransitionEventHandler<...> | undefined; } & { ...; } & { ...; }): ReactElement<...>', gave the follow

{
  ...
  return (
    <>
      <ModalOverlay visible={visible} />
      <ModalWrapper className={className} onClick={maskClosable ? onMaskClick : null} tabIndex="-1" visible={visible}>
        <ModalInner tabIndex="0" className="modal-inner">
          {children}
        </ModalInner>
      </ModalWrapper>
    </>
  );
}

const ModalWrapper = styled.div`
  box-sizing: border-box;
  display: ${(props) => (props.visible ? "block" : "none")};
  ...
`;

const ModalOverlay = styled.div`
  box-sizing: border-box;
  display: ${(props) => (props.visible ? "block" : "none")};
  ...
`;

const ModalInner = styled.div`
  ..
`

이번 에러는 `styled-components`에 사용되는 프로퍼티(`props.visible`)의 타입 지정이 되어 있지 않아 생긴 문제였다.

처음에는 위에 이미 타입 지정해 놓은 `ModalProps`를 사용하려고 했는데 이 경우 onClose, children까지 필수 프로퍼티라 `styled-components` 내부에서도 반드시 값을 사용해야 했다.

type ModalProps = {
  className?: string;
  onClose: (e: React.MouseEvent<HTMLElement, MouseEvent>) => void;
  maskClosable?: boolean;
  visible: boolean;
  children: ReactNode;
};

아예 전부 선택적 프로퍼티로 변경할까도 했는데 그렇게 하니 `TransactionList` 컴포넌트에서 오류가 났던 것으로 기억한다.(코드 작성한 지 꽤 시간이 흘러서 기억...이...)

어떻게 문제를 해결해야 할지 고민하다가 자세히 살펴보니 현재 `Modal` 컴포넌트에서 쓰이는 값은 `visible`, `children` 뿐이라는 걸 확인했다. 어차피 필요도 없는 프로퍼티 때문에 타입 지정하고 오류 해결하느라 괜히 고생을 한 느낌... 나중에 사용될 프로퍼티일 수도 있지만 우선 지금은 일절 안 쓰는 속성이므로 그냥 지워주기로 했다.

추후 필요하면 다시 추가 예정이다.

 

이후 `styled-components` 코드도 살짝 수정했다. props를 사용할 때 필요한 값의 타입을 아래와 같이 지정해 부었다. 

const ModalWrapper = styled.div<{ visible: boolean }>`
...
`;

 

이렇게 고쳐 본 최종 `Modal`은 다음과 같다.

import React, { ReactNode } from "react";
import styled from "styled-components";

type ModalProps = {
  visible: boolean;
  children: ReactNode;
};

function Modal({ visible, children }: ModalProps) {
  return (
    <>
      <ModalOverlay visible={visible} />
      <ModalWrapper visible={visible}>
        <ModalInner className="modal-inner">{children}</ModalInner>
      </ModalWrapper>
    </>
  );
}

const ModalWrapper = styled.div<{ visible: boolean }>`
  box-sizing: border-box;
  display: ${(props) => (props.visible ? "block" : "none")};
  ...
`;

const ModalOverlay = styled.div<{ visible: boolean }>`
  box-sizing: border-box;
  display: ${(props) => (props.visible ? "block" : "none")};
  ...
`;

const ModalInner = styled.div`
  ...
`;

export default Modal;

 

👉 이렇게 TransactionList, Modal 컴포넌트의 모든 에러 수정 완료!

 


참고

 

[TS] TypeScript에서 Styled-component 사용하기

TypeScript에서 styled-component를 사용해보자.

velog.io

 

반응형