본문 바로가기
웹 프로젝트/👨‍👨‍👧‍👧소셜 가계부

[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

 

반응형