이전 마이그레이션 관련 포스팅
- JavaScript → TypeScript 마이그레이션 1(세팅, App, AppRouter)
- JavaScript → TypeScript 마이그레이션 2(대시보드 페이지, 대시보드 관련 컴포넌트: state 빈 배열 초기화 never type 에러 해결)
- JavaScript → TypeScript 마이그레이션 3(가계부 페이지, Button 컴포넌트: onClick 함수 타입 지정, 자잘한 오류들 해결)
- JavaScript → TypeScript 마이그레이션 4(가계부 페이지 관련 컴포넌트: TransactionList, Modal)
- JavaScript → TypeScript 마이그레이션 5(Auth: Test 계정 Auto Fill 구현, Community: pagination 마이그레이션)
📚 index
기존 index.js 파일은 다음과 같다.
import React from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App";
import { Provider } from "react-redux";
import { createStore } from "redux";
import rootReducer from "./modules/rootReducer";
import { persistStore } from "redux-persist";
import { PersistGate } from "redux-persist/integration/react";
const store = createStore(rootReducer);
export const persistor = persistStore(store);
createRoot(document.getElementById("root")).render(
<Provider store={store}>
<PersistGate loading={null} persistor={persistor}>
<App />
</PersistGate>
</Provider>,
);
리덕스 코드와 persist 코드가 작성되어 있다.
이제 이 js 파일을 ts파일로 고치고, 발생하는 에러를 하나하나 해결해 보겠다.
index 관련 에러 해결
1️⃣ Argument of type 'HTMLElement | null' is not assignable to parameter of type 'Container'.
Type 'null' is not assignable to type 'Container'
👉 HTMLElement 타입 관련 에러이다.
'null' 유형은 'Container' 유형에 할당할 수 없습니다. 라는 문구를 봐서는, null 체크를 통해 에러를 해결할 수 있겠다는 생각이 들었다.
✅ 해결
const rootContainer = document.getElementById("root");
if (rootContainer) {
createRoot(rootContainer).render(
<Provider store={store}>
<PersistGate loading={null} persistor={persistor}>
<App />
</PersistGate>
</Provider>,
);
if문을 이용한 null 체크를 통해 오류를 해결했다.
2️⃣ 'Provider' refers to a value, but is being used as a type here. Did you mean 'typeof Provider'?
👉 해석: 'Provider'는 값을 의미하지만 여기서는 type으로 사용됩니다. 혹시 'typeof Provider'을 의미하는 건가요?
✅ 해결
에러를 해석해 봐도 무슨 의미인지 알 수 없어서(값을 의미하지만 타입으로 사용되었다니...?) 원인이 뭔지 한참 찾았다.
결론은 index.js 파일을 index.ts 파일로 변경한 탓에 ts파일에서 jsx 문법을 해석하지 못해 발생한 에러였다.
React 컴포넌트인 Redux Provider을 인식하려면 파일 확장자를 tsx로 바꾸어 주어야 한다. 파일 확장자를 tsx로 바꾸어주었더니 바로 에러가 사라졌다.
📚 리덕스
리덕스 모듈
먼저 루트 리듀서로 묶어줄 리덕스 모듈들을 TS로 변경해 주었다.
혼자 이것저것 찾아보면서 하다 보니 시간이 많이 걸렸는데, 그러던 차에 참고할 만한 문서를 발견했다.
참고해서 하나하나 따라 해 보았다.
1. 액션타입 정의
가장 먼저 js 코드 형식을 ts로 바꾸어 준다.
다음 해야 할 일은 액션 타입에 `as const` 키워드를 추가해 주는 일이다.
/* ----------------- 액션 타입 ------------------ */
export const SET_BUDGET = "transactions/SET_BUDGET";
export const SET_INCOME = "transactions/SET_INCOME";
export const SET_EXPENSE = "transactions/SET_EXPENSE";
export const CLEAR_TRANSACTIONS_DATA = "transactions/CLEAR_TRANSACTIONS_DATA";
👇 변경 후
/* ----------------- 액션 타입 ------------------ */
export const SET_BUDGET = "transactions/SET_BUDGET" as const;
export const SET_INCOME = "transactions/SET_INCOME" as const;
export const SET_EXPENSE = "transactions/SET_EXPENSE" as const;
export const CLEAR_TRANSACTIONS_DATA = "transactions/CLEAR_TRANSACTIONS_DATA" as const;
처음에 `as const`를 몰랐을 때에는 생각 없이 액션 객체 전체 타입부터 정했다가
Property 'payload' does not exist on type 'TransactionAnalyticsAction'.
Property 'payload' does not exist on type '{ type: string; }
라는 에러를 만났다.
참고한 문서에 따르면 action.type의 값을 추론하는 과정에서 action.type이 string으로 추론되어 발생하는 문제라고 한다.
뒤에 `as const`를 붙여주면 action.type이 `transactions/SET_BUDGET`와 같이 실제 문자열 값으로 추론되어 에러가 사라진다.
2. 액션 생성함수 타입 정의
/* ----------------- 액션 생성 함수 ------------------ */
export const setBudget = (budget: { monthYear: string; amount: number }) => ({
type: SET_BUDGET,
payload: budget,
});
export const setIncome = (income: number) => ({
type: SET_INCOME,
payload: income,
});
export const setExpense = (expense: number) => ({
type: SET_EXPENSE,
payload: expense,
});
export const clearTransactionsData = () => ({
type: CLEAR_TRANSACTIONS_DATA,
});
매개변수의 타입을 모두 지정해 주고, 다음과 같이 액션 객체 전체 타입도 지정해 주었다.
type TransactionAnalyticsAction =
| ReturnType<typeof setBudget>
| ReturnType<typeof setIncome>
| ReturnType<typeof setExpense>
| ReturnType<typeof clearTransactionsData>;
`ReturnType<typeof _____>`는 특정 함수의 반환값을 추론하는 코드이다.
위에서 액션 타입들을 선언할 때 `as const`를 붙여주지 않았다면 이 부분이 제대로 동작하지 않아 에러가 발생한다.
3. 모듈의 초기 상태 타입 정의하기
/* ----------------- 모듈의 초기 상태 ------------------ */
interface TransactionAnalyticsState {
budget: { monthYear: string; amount: number };
income: number;
expense: number;
}
const initialState: TransactionAnalyticsState = {
budget: { monthYear: "", amount: 0 },
income: 0,
expense: 0,
};
원래 하던 대로 모듈의 초기 상태 타입과 초기값을 정의해 준다.
4. 리듀서 타입 정의하기
/* ----------------- 리듀서 ------------------ */
const transactionAnalyticsReducer = (
state: TransactionAnalyticsState = initialState,
action: TransactionAnalyticsAction,
): TransactionAnalyticsState => {
switch (action.type) {
case SET_BUDGET:
return {
...state,
budget: action.payload,
};
case SET_INCOME:
return {
...state,
income: action.payload,
};
case SET_EXPENSE:
return {
...state,
expense: action.payload,
};
default:
return state;
}
};
state, action 타입으로 기존에 정의해 놓았던 `TransactionAnalyticsState`, `TransactionAnalyticsAction` 타입을 각각 지정해 주었다. 추가로 반환값의 타입도 `TransactionAnalyticsState`로 설정하였다.
🚌 🚌 🚘 🚌 🚌 여기까지 리덕스 모듈 마이그레이션 완료.
루트 리듀서
기존 루트 리듀서 코드는 다음과 같다.
//rootReducer.js
import { combineReducers } from "redux";
import { persistReducer } from "redux-persist";
import storage from "redux-persist/lib/storage";
import userReducer from "./user";
import transactionReducer from "./transactions";
import transactionAnalyticsReducer from "./transactionAnalytics";
import { encryptTransform } from "redux-persist-transform-encrypt";
const persistConfig = {
key: "root",
whitelist: ["user"],
storage,
};
const rootReducer = combineReducers({
user: userReducer,
transactions: transactionReducer,
transactionAnalytics: transactionAnalyticsReducer,
});
const reducer = persistReducer(
{
...persistConfig,
transforms: [
encryptTransform({
secretKey: process.env.REACT_APP_PERSISTOR_SECRET_KEY,
}),
],
},
rootReducer,
);
export default reducer;
코드를 ts로 바꾸니 에러가 쏟아진다...;
루트 리듀서 관련 에러 해결
1️⃣ Type 'string | undefined' is not assignable to type 'string'.
Type 'undefined' is not assignable to type 'string'.
👉 해석: 'string | undefined' 유형은 'string' 유형에 할당할 수 없습니다.
'undefined' 유형은 'string' 유형에 할당할 수 없습니다.
Secret key를 지정할 때, .env 내부에 값이 존재하지 않는 경우 undefined 타입의 값이 키에 저장될 수도 있으므로 발생하는 에러였다.
✅ 해결
secretKey: process.env.REACT_APP_PERSISTOR_SECRET_KEY || "",
`process.env.REACT_APP_PERSISTOR_SECRET_KEY`값이 falsy 한 경우, 즉 값이 존재하지 않거나 빈 문자열인 경우에 빈 문자열 ""을 대신 사용하도록 하여 오류를 해결했다.
➕ 반환값 유추 처리
export type RootState = ReturnType<typeof rootReducer>;
루트 리듀서의 반환값을 유추해 주는 코드이다.
추후 이 타입을 컨테이너 컴포넌트에서 불러와서 사용해야 하므로 내보내준다.
2️⃣ Argument of type 'Reducer<{ user: UserData; transactions: TransactionList; transactionAnalytics: TransactionAnalyticsData; }, UserAction | TransactionsAction | TransactionAnalyticsAction, Partial<...>>' is not assignable to parameter of type 'Reducer<Partial<{ user: never; transactions: never; transactionAnalytics: never; }>, UserAction | TransactionsAction | TransactionAnalyticsAction>'.
rootReducer의 초기 상태 타입이 `Reducer<Partial<{ user: never; transactions: never; transactionAnalytics: never; }>, UserAction | TransactionsAction | TransactionAnalyticsAction>`로 지정되어 있는데, 실제 사용하고 있는 리듀서는 `Reducer<{ user: UserData; transactions: TransactionList; transactionAnalytics: TransactionAnalyticsData; }, UserAction | TransactionsAction | TransactionAnalyticsAction>`로 지정되어 있다는 에러 메시지이다.
🤔 그럼 Reducer의 내부 데이터의 디폴트 타입이 never라는 걸까? 다른 타입으로 지정하는 법이 따로 있는 건가?
갖가지 의문을 가지고 오랜 시간 검색해 보았으나 오류를 해결하지 못했다.
그때쯤 리덕스+타입스크립트 관련한 다른 문서들을 여러 개 찾아보면서 rootReducer 작성하는 방법을 그대로 따라 해 보기 시작했다. 거의 베끼다시피 코드를 작성했는데도 위의 똑같은 부분에서 오류가 발견되자 슬슬 문제가 다른 곳에 있는 건 아닌가 하는 생각이 들었다.
나의 경우 레퍼런스로 삼은 다른 리덕스 코드와 달리 리덕스 데이터를 Local Storage에 저장하고 암호화하기 위해 두 개의 라이브러리를 추가로 사용하고 있는 상황이었다.
정황상 두 개의 라이브러리에서 타입 문제가 발생한 것일지도 모르겠다는 합리적인 의심이 들기 시작했다.
라이브러리 재설치 (@type 버전으로)
그래서 내가 가장 먼저 시도한 일은 혹시 라이브러리에서 타입을 인식 못 하는 건가 해서
npm i --save-dev @types/redux-persist
npm i @types/redux-persist-transform-encrypt
위와 같이 두 개의 라이브러리를 type 적용된 버전으로 설치하는 일이었다.
하지만 효과가 없었다.
코드 내부에서 타입 지정해 주기
const reducer = persistReducer(
{
...persistConfig,
transforms: [
encryptTransform({
secretKey: process.env.REACT_APP_PERSISTOR_SECRET_KEY,
}),
],
},
rootReducer,
);
라이브러리 문제가 아니라 코드 자체에서 해결해야 하는 문제라는 걸 확인했다.
내 코드가 일반적인 루트 리듀서 코드와 다른 부분 중 가장 핵심은 위의 코드였다.
이것저것 바꾸어 보다가 `persistReducer` 함수의 타입을 지정해 주어야 하는 것이 아닌가 하는 생각에 시도해 보았다.
Argument of type 'Reducer<{ user: UserData; transactions: TransactionList; transactionAnalytics: TransactionAnalyticsData; }, TransactionsAction | TransactionAnalyticsAction | UserAction, Partial<...>>' is not assignable to parameter of type 'Reducer<{ user: UserData; transactions: TransactionList; transactionAnalytics: TransactionAnalyticsData; }, Action>'.
Types of parameters 'action' and 'action' are incompatible.
Type 'Action' is not assignable to type 'TransactionsAction | TransactionAnalyticsAction | UserAction'.
👉 해석: 'Reducer<{user: UserData; transactions: TransactionList; transactionAnalyticsData; }, Transactions|TransactionsAnalyticsAction|UserAction, Partial<...>>' 유형의 인수는 'Reducer<{user: UserData; transactions: TransactionList; transactionAnalyticsData; }, Action>' 유형의 매개 변수에 할당할 수 없습니다.
매개 변수 '액션'과 '액션' 유형이 호환되지 않습니다.
'Action' 유형은 'Transactions Action | Transaction Analytics Action | User Action' 유형에 할당할 수 없습니다.
type RootAction = UserAction | TransactionsAction | TransactionAnalyticsAction;
const reducer = persistReducer<RootState, RootAction>(
{
...persistConfig,
transforms: [
encryptTransform({
secretKey: process.env.REACT_APP_PERSISTOR_SECRET_KEY || "",
}),
],
},
rootReducer,
);
직접 각각의 리덕스 모듈들에서 생성한 Action을 import 해서 위와 같이 State, Action을 지정해 주었는데 에러가 발생했다.
관련해서 이것저것 시도해 보았지만 이런 방식으로는 결국 문제를 해결할 수 없었다.
✅ 해결
참고: https://github.com/rt2zz/redux-persist/issues/1184
발생하는 에러 메시지들을 계속해서 추적해 나가다 보니, 글을 하나 발견했다.
나와 같은 오류를 겪고 있는 개발자의 질문글이었다. 다양한 답변들이 달려있기에 하나하나 시도해 보았다.
그러던 중 에러를 싹 해결해 주는 코드를 발견했다.
1️⃣
// 1번 방법
const reducer = persistReducer<any, any>(
{
...persistConfig,
transforms: [
encryptTransform({
secretKey: process.env.REACT_APP_PERSISTOR_SECRET_KEY || "",
}),
],
},
rootReducer,
);
기본적으로 리듀서 함수의 타입은 다음과 같이 지정할 수 있다.
Reducer<TopicBasicState,TopicAction>
제네릭 타입 매개변수는 <State, Action>으로 첫 번째 매개변수 State는 상태의 타입을, 두 번째 매개변수 Action은 액션의 타입을 나타낸다.
위 1번 코드는 임시로 `any` 타입을 사용하여 모든 유형의 상태와 액션을 수용하도록 하는 코드이다. 이렇게 하면 TypeScript가 해당 매개변수의 타입에 대해 추론을 수행하지 않고 모든 유형을 허용하게 되어 해당 부분에 오류가 발생하지 않는다.
2️⃣
// 2번 방법
const reducer = persistReducer<RootState>(
{
...persistConfig,
transforms: [
encryptTransform({
secretKey: process.env.REACT_APP_PERSISTOR_SECRET_KEY || "",
}),
],
},
rootReducer as any,
);
`as any`는 TypeScript에서 타입을 임시로 무시하고 특정 타입으로 캐스팅할 때 붙여주는 키워드이다.
일반적으로 TypeScript가 타입을 추론할 수 없거나 제대로 추론하지 못할 때 사용되며 이를 통해 코드를 강제로 컴파일하고 오류를 피할 수 있다.
`as any`에 대한 예시도 살짝 찾아보았다.
// as any 적용x
let x: number;
x = "hello"; // 오류 발생
// as any 적용
let x: number;
x = "hello" as any; // 정상 실행
😭
이렇게 에러를 해결하기는 했지만 `any`를 사용하는 방법은 임시적인 해결책이다.
any로 타입을 지정해 놓고 실제로 타입을 잘못 사용하는 경우 코드의 안정성을 저해할 수 있으므로 가능하면 실제 상태와 액션의 타입을 사용하여 제네릭을 설정하는 것이 좋다.
any 사용은 최대한 지양하라는 말을 타입스크립트 공부 시작한 뒤부터 꾸준히 들어왔다. 따라서 any를 사용하지 않고 해결할 수 있는 방법을 찾아봤지만 쉽지가 않았다...
그래서 우선은 any로 해놓고, 추후 persistReducer 함수 타입 관련해 더 정보를 찾게 되면 코드를 수정하는 방식으로 문제를 해결하기로 했다. 지금은 persist 라이브러리 관련해 정보 자체가 적은 상황이라, 원문으로 검색해도 나오는 게 몇 없다.
그래서 일단 여기까지 모든 컴포넌트와 함수, 상수에 대한 마이그레이션 완료.
다음은 중구난방으로 지정한 타입을 정리할 차례다.
그건 다음 포스팅에...