第三週第一至五天:將現有專案改寫成 TypeScript

第三週第一至五天:將現有專案改寫成 TypeScript

專案原始碼:anna-luyufeng/whereintheworld-fe-challenge

這週會將以前用 Create React App(CRA)的專案改寫成 TypeScript 來複習前兩週所學,並把每天的進度 commit 到新的分支 typescript,讓日後有興趣的人仍能從 master 複製一份來實作練習。

我個人習慣在導入或實驗新東西時,制定計畫按部就班來執行,避免自己做到一半不知道在幹嘛,也比較好掌握進度。

三階段漸進式改寫

這是 React TypeScript Cheatsheets 的 3 Step Process 提到的改寫流程,會大致依照這個流程進行改寫,過程中遇到的問題或想法會直接放在「每日改寫記錄」內:

  1. Just Make It Work 讓專案能啟動就好

    • 新增 tsconfig.json

    • 關閉「不允許隱含任意值」選項 noImplicitAny(預設即為關閉)

    • 改用 TypeScript 副檔名 .ts.tsx

  2. Be Explicit 讓型別明確

    • 啟用「不允許隱含任意值」選項 noImplicitAny

    • 常見型別

    • 第三方函式庫型別

  3. Strict Mode 嚴格模式

    • 啟用「所有嚴格型別檢查」選項 strict

每日改寫記錄

Just Make It Work 讓專案能啟動就好

安裝 TypeScript 和相關宣告檔案

# npm
npm install --save typescript @types/node @types/react @types/react-dom @types/jest

# yarn
yarn add typescript @types/node @types/react @types/react-dom @types/jest

jsconfig.json 重新命名為 tsconfig.json ,並新增選項

本來直接新增 tsconfig.json,但試著 yarn start 後報錯 Error: You have both a tsconfig.json and a jsconfig.json. If you are using TypeScript please remove your jsconfig.json file. 才發現 jsconfig.jsontsconfig.json 不能同時存在。

{
  "compilerOptions": {
    "allowJs": true, // 允許編譯器編譯 JavaScript 文件
    "checkJs": true // 允許對 JavaScript 文件(.js 和 .jsx 文件)進行型別檢查
  }
}

將檔案改用 TypeScript 副檔名

將 JavaScript 文件的文件副檔名更改為 .tsx(對於包含 JSX 的文件)或 .ts(對於不包含 JSX 的文件)。例如,將 App.jsx 重新命名為 App.tsx,將 index.js 重新命名為 index.ts

# 快速將 src 目錄底下副檔名為 .js 與 .jsx 更改為 .ts 與 .tsx
find src -name "*.js" -exec sh -c 'mv "$0" "${0%.js}.ts"' {} \;
find src -name "*.jsx" -exec sh -c 'mv "$0" "${0%.jsx}.tsx"' {} \;

改完副檔名後共 19 個地方報錯。本來以為會不能 commit,結果發現專案還沒有安裝 ESLint,之後再來安裝。

新增 "allowSyntheticDefaultImports": true 選項

允許在 TypeScript 中使用預設導入語法來導入 CommonJS 模組。

讓 TypeScript 能夠識別 CSS Modules 的 .scss 檔案

複習第一週第六天:宣告檔案與內建物件

  1. 在專案根目錄或適當的子目錄中,新增一個名為 typings 的資料夾。這個資料夾將存放自定義型別宣告。

  2. typings 資料夾內,新增一個名為 scss-modules.d.ts 的檔案。這個檔案將包含用於定義 CSS Modules 的型別宣告。

  3. scss-modules.d.ts 檔案中,添加以下內容:

     declare module '*.module.scss' {
       const classes: { [key: string]: string };
       export default classes;
     }
    

    這段程式碼告訴 TypeScript 編譯器,所有具有 .module.scss 副檔名的檔案將導出一個物件,其鍵為 string 類型,值也為 string 類型。這表示 CSS 樣式名稱與其對應的值。

  4. 確保 tsconfig.json 檔案中的 include 選項包含了剛剛新增的 typings 資料夾。例如:

     {
       "compilerOptions": {
         // 編譯選項...
       },
       "include": ["src", "typings"]
     }
    

ERROR TS2345: Type 'string' is not assignable to type 'PaletteMode'

複習第一週第五天:函式型別與型別斷言第二週第一天:型別別名與字串字面量型別第一週第六天:宣告檔案與內建物件

這個錯誤是因為 TypeScript 無法確定 palette.mode 的型別。PaletteMode 字串字面量型別預期值為 'light''dark',但在程式碼中它被推斷為 string 型別。因此只要讓 TypeScript 知道 palette.mode 只會是 'light''dark' 不會是其他字串,就不會出現型別錯誤。

參考 MUI 官網說明,用 declare module 來將 palette 的型別 PaletteOptions 加上自定義顏色 neutral 型別。

declare module '@mui/material/styles' {
  interface PaletteOptions {
    neutral: PaletteOptions['primary'];
  }
}

const lightTheme: PaletteOptions = {
    // ...
}

const darkTheme: PaletteOptions = {
    // ...
}

const getDesignTokens = (isDarkMode: boolean): ThemeOptions => ({
  palette: {
    ...(isDarkMode ? darkTheme : lightTheme),
  },
  // ...
});

ERROR TS2803: Type 'IterableIterator<number>' can only be iterated through when using '--downlevelIteration' flag or with a '--target' of 'es2015' or higher

要可以迭代 Array(8).keys() 需要 ECMAScript 2015(即 ES6)標準,將 tsconfig.jsontarget 設定為 'es2015' 或是更新的版本,如 'es2017''es2018' 等。

// tsconfig.json
{
  "compilerOptions": {
    // ...其他編譯選項
    "target": "es2015",
    // 或
    // "target": "es2017",
    // 或
    // "target": "es2018",
  },
  "include": ["src"]
}

ERROR TS2792: Did you mean to set the 'moduleResolution' option to 'nodenext', or to add aliases to the 'paths' option?

這個錯誤表示 TypeScript 編譯器無法導入模組,需要設定適合的模組解析方式。以下是 ChatGPT 對於不同模組解析方式的解釋:

  1. node:這是大多數情況下的推薦選擇。node 選項將導入解析過程與 Node.js 的模組解析算法保持一致。它將尋找 .ts.tsx.js.jsx.json 副檔名的文件,並支持解析 package.json 中的 mainmodule 屬性。它適用於 Node.js 應用程序和使用 Webpack 或其他打包器的前端應用程序。

  2. classic:這是 TypeScript 較早版本的預設解析策略。它首先尋找與導入語句相同的文件名(不包括副檔名),然後尋找具有相同名稱的文件夾,最後檢查文件夾中是否有 index.tsindex.js。通常,不需要使用 classic,除非正在使用舊版本的 TypeScript 或特定的構建工具。

  3. nodenext:這個選項是針對支持 ECMAScript 模組的 Node.js 12+ 版本。它遵循較新的模組解析策略,要求在導入路徑中包含文件副檔名。它還支持一些新功能,如條件導出和導入。

選擇適當的 moduleResolution 取決於專案需求和目標環境。在大多數情況下,應該選擇 node,因為它提供了最好的兼容性和靈活性。對於支持 ECMAScript 模組的 Node.js 12+ 版本,可以選擇 nodenext。只有在處理舊的 TypeScript 專案或特定構建工具時,才需要考慮使用 classic

// tsconfig.json
{
  "compilerOptions": {
    // ...其他編譯選項
    "moduleResolution": "node",
  },
  "include": ["src"]
}

ERROR TS1323: Dynamic imports are only supported when the '--module' flag is set to 'es2020', 'es2022', 'esnext', 'commonjs', 'amd', 'system', 'umd', 'node16', or 'nodenext'.

這個錯誤表明 TypeScript 編譯器無法識別動態導入(import()),需要將 tsconfig.json 的模組系統 module 設定為支援動態導入的值之一。以下是 ChatGPT 對於不同模組系統的解釋:

  1. commonjs:這是 Node.js 的標準模組系統,適用於 Node.js 應用程序和具有網頁打包器(如 Webpack)的瀏覽器應用程序。

  2. es2020, es2022, esnext:這些選項表示使用 ECMAScript 模組系統,它們分別針對 ES2020、ES2022 和最新的 ECMAScript 草案。適用於現代瀏覽器和支援 ECMAScript 模組的 Node.js 版本。

  3. amdsystem:這些選項適用於需要使用非標準模組加載器(如 RequireJS)的應用程序。

  4. umd:這個選項表示通用模組定義,它允許您的代碼在 CommonJS、AMD 和全局變量的環境下運行。這對於要在多種環境下運行的庫或框架非常有用。

  5. node16nodenext:這些選項專為支援 ECMAScript 模組的 Node.js 12+ 版本而設計。nodenext 還提供了更好的支援,如條件導出和導入。

在大多數情況下,commonjsesnext 是最常用的選項。如果專案主要針對 Node.js,可以選擇 commonjs;如果主要針對現代瀏覽器並使用了打包器,則可以選擇 esnext。對於支援 ECMAScript 模組的 Node.js 12+ 版本,可以選擇 node16nodenext

// tsconfig.json
{
  "compilerOptions": {
    // ...其他編譯選項
    "module": "esnext",
  },
  "include": ["src"]
}

ERROR TS2554: reportWebVitals() Expected 1 arguments, but got 0.

根據註解內的 CRA 官網說明,因為我只要單純紀錄,按照官網說明改成:

reportWebVitals(console.log);

ERROR TS2769: No overload matches this call.

MUI Button 使用自定義的顏色時,除了擴充 '@mui/material/styles' 模組的型別 PaletteOptions 也需要在 '@mui/material/Button' 模組的 ButtonPropsVariantOverrides 型別加上自定義的顏色。

import '@mui/material/Button'; // 記得匯入原本模組的宣告檔案,不然會變成完全覆蓋,導致找不到原本 Button 的型別定義而報錯

declare module '@mui/material/Button' {
    interface ButtonPropsColorOverrides {
        neutral: true
    }
}

ERROR TS2554: useGetAllCountriesQuery() Expected 1-2 arguments, but got 0.

我這裡是直接拿所有的國家資料,不需要傳參數,經 ChatGPT 指點,可以傳一個空物件作為參數:

const { data = [], isLoading } = useGetAllCountriesQuery({});

ERROR in src/reduxModules/country/countryApi.ts

TS2339: Property 'status' does not exist on type 'unknown'.

countryResult.data.status 發現寫錯報錯的物件結構,另外參考官網的寫法改成:

if (countryResult.error)
    return { error: countryResult.error as FetchBaseQueryError };

TS2339: Property 'borders' does not exist on type 'unknown'.

TS2698: Spread types may only be created from object types.

複習第一週第五天:函式型別與型別斷言第一週第六天:宣告檔案與內建物件

這兩個錯誤則是要用型別斷言讓 TypeScript 知道 countryResult.data 的資料型別 ,因此需要新增宣告檔案來定義國家資料型別 Country 後指給 countryResult.data

// typings 底下新增 query.d.ts
type Language = {
    iso639_1: string;
    iso639_2: string;
    name: string;
    nativeName: string;
}

type Currency = {
    code: string;
    name: string;
    symbol: string;
}

interface Country {
    alpha3Code: string;
    borders: string[];
    capital: string;
    currencies: Currency[];
    flag: string;
    languages: Language[];
    name: string;
    nativeName: string;
    population: number;
    region: string;
    subregion: string;
    topLevelDomain: string[];
}
// src/reduxModules/country/countryApi.ts

// 第 30 行
const countryBorders = (countryResult.data as Country).borders;

// 第 50 行
...(countryResult.data as Country),

TS2339: Property 'then' does not exist on type

這個錯誤是因為 TypeScript 編譯器無法確定 fetchWithBQ 的回傳型別 MaybePromise 是否有 then 屬性。我原本的寫法沒有很好,因為已經使用 async/await 語法,其實可以直接用 await 來取得 fetchWithBQ 的結果。會將這段重構讓它更容易閱讀與理解:

// 封裝成獨立函式
async function fetchCountryBorderData(border) {
  const response = await fetchWithBQ({
    url: `/alpha/${border}`,
    params: {
      fields: "name,alpha3Code",
    },
  });
  return response.data;
}

const countryBordersResults = await Promise.all(
  countryBorders.map((border) => fetchCountryBorderData(border))
);

TS2339: Property 'data' does not exist on type 'unknown[]'.

這邊是透過 API 來取得 Country 內 Border Countries 對應的 alpha3Code ,因為 Country 資料需要用 alpha3Code 取得。思考過後覺得如果拿不到資料就把對應的 Border Country 濾掉不顯示,不需要因為它發生錯誤而不顯示整個 Country 的資料:

return {
  data: transformCountry({
    ...(countryResult.data as Country),
    borders: countryBordersResults.filter(Boolean),
  }),
};

Be Explicit 讓型別明確

複習第一週第四天:物件型別之介面與陣列型別第一週第五天:函式型別與型別斷言第二週第四天:泛型與宣告合併

啟用「不允許隱含任意值」選項 noImplicitAny

// tsconfig.json
{
  "compilerOptions": {
    "noImplicitAny": true,

    // 其他編譯器選項...
  },
  "include": ["src"]
}

啟用後出現共 23 個錯誤,因為每個錯誤都紀錄怎麼修的會花太多時間,簡單紀錄改錯誤時的學習與心得:

  • 如何將同個資料拆分成列表與細節不同用途的型別

      // typings/query.d.ts
      interface CountryList {
          alpha3Code: string;
          name: string;
          capital: string;
          flag: string;
          independent: boolean;
          population: number;
          region: string;
      }
    
      interface CountryDetail extends CountryList {
          borders: CountryBorder['name'][];
          currencies: Currency[];
          languages: Language[];
          subregion: string;
          topLevelDomain: string[];
          nativeName: string;
      }
    
  • 轉換不同用途的資料直接拆成兩個獨立轉換函式各別處理比較容易維護與閱讀

      // src/reduxModules/country/transform.ts
      export const transformCountryList = (country: CountryList) => {
        // ...
      };
    
      export const transformCountry = (country: CountryWithFullBorders) => {
        // ...
      };
    
  • 學到用 Omit<Type, Keys> 來將原本的定義好的型別來排除部分屬性後重新定義(直接繼承的話,重複定義的屬性型別需要相容於原本的型別)

      interface TransformedCountryDetail extends Omit<
          CountryWithFullBorders,
          "currencies" | "languages" | "population" | "topLevelDomain"
      >
    
  • 型別斷言很常用

  • 如果是在使用第三方函式庫的地方報錯,先到函式庫的官網看有沒有 TypeScript 的說明可以參考,不要埋頭苦幹。

  • 自定義的型別要特別注意其正確性,一千髮而動全身,才不會錯誤越改越多。

Strict Mode 嚴格模式

複習第一週第三天:探索型別推論與聯合型別第二週第四天:泛型與宣告合併

啟用「所有嚴格型別檢查」選項 strict

// tsconfig.json
{
  "compilerOptions": {
    // ...其他編譯選項
    "strict": true
  },
  "include": ["src"]
}

啟用後一共發現了 9 個錯誤,不過這次修正起來速度明顯加快了,有熟能生巧的感覺:

  • 學會 useState() 用泛型顯式指定型別 useState<CountryList[]>([])

  • 被 TypeScript 抓到 CountryCard 元件屬性的型別指錯的問題

安裝 ESLint

複習與更新第二週第五天:程式碼檢查

除了原本 Getting Started | typescript-eslint 提到的編譯選項,下一頁的說明 Linting with Type Information | typescript-eslint 建議還需要在 .eslintrc.js 增加下列選項:

/* eslint-env node */
module.exports = {
  //...
  parserOptions: {
    project: true, // 告訴 ESLint 如何解析 TypeScript 程式碼。將它設定為 true 表示啟用對 TypeScript 程式碼的支援。
    tsconfigRootDir: __dirname, // 這個選項指定 TypeScript 設定檔案(tsconfig.json)所在的根目錄。在這個例子中,__dirname 是一個 Node.js 全域變數,代表目前模組的目錄路徑。
  },
  extends: [
    //...
    // 包含了一些額外的 TypeScript 規則,這些規則需要型別檢查才能正確運行。
    'plugin:@typescript-eslint/recommended-requiring-type-checking',
  ],
};

但加完以上的選項 .eslintrc.js 報錯:

我翻找 typescript-eslint 的官方文件與 GitHub Issues 沒有特別提到要做額外的設定,本來是用錯誤提示第二個方法「Change that TSConfig to include this file」,但越想越不對勁,也有問 ChatGPT 它也不推薦,比較推薦的做法是第三個「Create a new TSConfig that includes this file and include it in your parserOptions.project」。

意思就是創建一個單獨的 TypeScript 配置檔案(例如 tsconfig.eslint.json),專門用於 ESLint。在這個檔案中,將包含原始的 tsconfig.json 檔案以及 .eslintrc.js 檔案。

// tsconfig.eslint.json
{
  "extends": "./tsconfig.json", // 繼承原始 tsconfig.json
  "include": ["src", "typings", ".eslintrc.js"] // 包含 .eslintrc.js 文件
}

然後,在 .eslintrc.js 中將 parserOptions.project 指向新的 TypeScript 配置檔案。

// .eslintrc.js
module.exports = {
  // ...
  parserOptions: {
    project: "./tsconfig.eslint.json", // 指向新的配置檔案
    // ...
  },
  // ...
};

專案的改寫終於告一個段落了!看到 Terminal 沒有任何紅色的 Error 時真的好感動!

準備來寫這三週學習計畫的心得總結啦!咱們明天見 👋