🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付

你好,我是對函數式編程情有獨鍾的全端工程師トウカ。因為這是我第一次參加聖誕倒數日曆,能夠受到大家的溫暖目光閱讀,我非常感激。

前言

大家有沒有過這樣的經歷?在生產環境中,應用程式突然崩潰。查看日誌時發現 TypeError: Cannot read property 'foo' of undefined😇

當我從Java後端開發轉向JavaScript/TypeScript的前端或全端開發時,雖然我以為自己擺脫了Java的NullPointerException地獄,卻又掉入了JavaScript的TypeError: undefined is not a function地獄。

TypeScript的出現大大改善了型別的安全性,但在錯誤處理方面,似乎仍然有很多挑戰待解決。

本文將追溯JavaScript和TypeScript中的錯誤處理演變,並解釋現代的各種方法。

通往後端的門

2009年,Node.js的出現對JavaScript開發者來說是革命性的。JavaScript可以在瀏覽器外運行,使得前端工程師能夠進入後端開發的領域,促進了開發工具和SSR等高端技術的發展。

然而,JavaScript並不是為了構建高可靠性的軟體而設計的語言。

動態類型

JavaScript是一種動態類型語言。類型錯誤只有在執行時才能發現。當你部署如下的錯誤代碼時,可能會在瀏覽器中無法預期地運行失敗,或者在伺服器上崩潰。

const count = 42
const upper = count.toUpperCase()
// 💀 TypeError: count.toUpperCase is not a function

在上面的例子中,乍一看是顯而易見的,但隨著程式變得複雜,即使你再聰明也難以追蹤問題。如此一來,每當改修程式時都會產生新的錯誤。這類錯誤在像Java這樣的靜態類型語言中可在編譯時捕獲,但在動態類型語言JavaScript中會成為運行時錯誤,只能通過測試來覆蓋。

<details><summary>【番外篇】型別安全的重要性🔥</summary>
這僅僅是一個例子,前幾天我在撰寫這篇文章時,Qiita出現了一個草稿頁面未正確顯示的故障。

https://x.com/qiita/status/1996834994170614251

我立即使用Chrome DevTools進行調查,並在本地繞過了問題。造成這一視覺問題的原因是,錯誤地將時區(如Asia/Tokyo)放入應放置語言區域(如ja-JP)的地方,導致運行時錯誤。如果這裡有類型檢查,則會產生編譯錯誤,就不會導致這樣的生產故障😌

image.png
</details>

null與undefined的雙重陷阱

Tony Hoare考慮到了程序員的便利性而發明了null,但他後悔地稱之為十億美元的失敗。[^1] 然而,JavaScript讓這個問題變得更糟,創造了第二個null,即undefined。

const obj = null
console.log(obj.foo)

const obj = {}
console.log(obj.bar) // 不存在的屬性,undefined

let fn // 未初始化的變數,undefined
fn()

各位工程師在打開壞掉的網站時,可能多次見到這樣的景象。

TypeError: Cannot read property 'foo' of null
TypeError: Cannot read property 'bar' of undefined
TypeError: undefined is not a function

從Java時代的NullPointerException地獄到JavaScript時代的TypeError地獄。

JavaScript大型開發的救世主

隨著用JavaScript開發的項目規模不斷增大,為了應對這一問題,當時出現了Microsoft的TypeScript(2012年)和Facebook的Flow(2014年)等靜態類型的解決方案。前者現在已成為事實上的標準。

TypeScript的出現讓null/undefined的問題可以在類型層面解決

function getUserName(user: User | undefined): string {
  console.log(user.name) // ❌ 編譯錯誤
  if (user === undefined) {
    return "Guest"
  }
  return user.name // ⭕️ 類型安全!
}

這是一次重大的進步,但仍然存在一些挑戰。

快樂路徑編碼的陷阱

像JavaScript這樣的腳本語言真是太棒了。不假思索地只寫快樂路徑即可。

async function getUser(id: string) {
  const response = await fetch(`/api/users/${id}`)
  const data = await response.json()
  return data
}

如果網絡無法連接呢?如果伺服器返回500呢?如果JSON解析失敗呢?所有這些都會成為運行時錯誤,導致應用程序崩潰。

各位是否幾次忘記寫try/catch呢?

async function getUser(id: string) {
  try {
    const response = await fetch(`/api/users/${id}`)
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`)
    }
    const data = await response.json()
    return data
  } catch (error) {
    console.error("Failed to fetch user:", error)
    throw error
  }
}

Java有檢查性異常的機制,若忘記撰寫try/catch就是編譯錯誤。

// HttpClient::send(...) throws IOException, InterruptedException

public void main() {
    try {
        HttpResponse<String> response =
            client.send(request, HttpResponse.BodyHandlers.ofString());
        System.out.println(response.statusCode());
        System.out.println(response.body());
    } catch (IOException e) {
        // 通信錯誤
        e.printStackTrace();
    } catch (InterruptedException e) {
        // ...
    }
}

不幸的是,TypeScript沒有等同的功能。也不打算實施。[^2] 函數是否會拋出錯誤,必須通過查看源碼或文件才能知道。雖然可以在文檔中使用JSDoc的@throws等標註,但維護成本及與源碼之間的脫節是需要擔心的問題。

因為甚至不知道是否會拋出錯誤,所以當然catch塊中的error都是unknown類型。

try {
  await fetchData()
} catch (error: unknown) {
  console.log(error.message) // ❌ 編譯錯誤
}

在實際的應用程序中,需要根據錯誤類型來改變處理。例如,

  • 如果是通信錯誤,可以稍後再試
  • 如果是狀態錯誤,則重試是無用的,想要報告錯誤

一種傳統的解決方法是定義自訂錯誤類別。

class NetworkError extends Error {
  constructor(message: string) {
    super(message)
    this.name = "NetworkError"
  }
}

class StatusError extends Error {
  constructor(message: string, public field: string) {
    super(message)
    this.name = "StatusError"
  }
}

try {
  // fetchData會根據情況拋出NetworkError或StatusError
  await fetchData()
} catch (error) {
  // error: unknown
  if (error instanceof NetworkError) {
    // 重試
  } else if (error instanceof StatusError) {
    // 報告錯誤
  } else {
    // 未知的錯誤?
  }
}

如果能夠將錯誤與型別資訊相關聯,那麼這個問題是否能被完美解決呢?

對型別安全錯誤的探索

2019年,neverthrow這個庫將Result型別引入TypeScript。[^3] 對於有接觸Rust的人來說,這是個熟悉的型別。

type Result<T, E> =
  | Ok<T, E> // 存放型別T的值(成功的情況下)
  | Err<T, E> // 存放型別E的值(失敗的情況下)

通過將像fetch等可能拋出錯誤的地方轉換為函數的返回值,從而將錯誤反映到類型層面。

import { Result, ok, err } from "neverthrow"

type FetchUserError =
  | { type: "NetworkError"; message: string }
  | { type: "NotFoundError"; userId: string }

async function fetchUser(id: string): Promise<Result<User, FetchUserError>> {
  try {
    const response = await fetch(`/api/users/${id}`)

    if (!response.ok) {
      if (response.status === 404) {
        return err({ type: "NotFoundError", userId: id })
      }
      return err({
        type: "NetworkError",
        message: `HTTP ${response.status}`,
      })
    }

    const data = await response.json()
    return ok(data)
  } catch (error) {
    // 實際上需要處理從response.json()拋出的錯誤
    return err({
      type: "NetworkError",
      message: error instanceof Error ? error.message : "Unknown error",
    })
  }
}

這樣錯誤處理的遺漏就可由編譯器檢查。

const result = await fetchUser("123")
result
  .map((user) => {
    console.log(user)
  })
  .mapErr((error) => {
    console.log(error)
  })

冗長的問題

當組合多個Result時,可以使用mapandThen等方法。

不過,實際的邏輯因為複雜,可能使代碼變得冗長而難以閱讀。[^4]
而且,對於未習慣函數式寫法的人來說,也會感到非常困難。

function fetchUser(id: string): Promise<Result<User, Error>>
function fetchProfile(id: string): Promise<Result<Profile, Error>>
function fetchAvatar(id: string): Promise<Result<Avatar, Error>>

const userDetail = fetchUser("123").andThen((user) =>
  fetchProfile(user.profileId).andThen((profile) =>
    fetchAvatar(profile.avatarId).andThen((avatar) =>
      ok({ user, profile, avatar })
    )
  )
)

在函數式社群中,著名的Haskell語言有do語法,能編寫這樣可讀性好的代碼。

userDetail = do
  user <- fetchUser "123"
  profile <- fetchProfile user.profileId
  avatar <- fetchAvatar profile.avatarId
  Right { user, profile, avatar }

其實在neverthrow之前的2017年,有一個強烈函數式思想的庫fp-ts中也存在類似Result的Either。

順帶一提,幾年前我有機會引入fp-ts,當時也有相似的印象。雖然也有類似模仿do語法的寫法,但因為語言並未原生支持,使用起來還是比較麻煩。

import { pipe } from "fp-ts/function"
import * as TE from "fp-ts/TaskEither"

const userDetail = pipe(
  TE.Do,
  TE.bind("user", () => fetchUser("123")),
  TE.bind("profile", ({ user }) => fetchProfile(user.profileId)),
  TE.bind("avatar", ({ profile }) => fetchAvatar(profile.avatarId))
  // TE.map(({ user, profile, avatar }) => ({ user, profile, avatar })),
)

像原生的書寫感覺

隨著時間的推進,利用型別安全的生成器[^5],safeTry被引入neverthrow,使得能夠使用類似async/await的方式來處理Result。

import { safeTry, ok, err } from "neverthrow"

const userDetail = await safeTry(async function* () {
  const user = yield* fetchUser("123")
  const profile = yield* fetchProfile(user.id)
  const settings = yield* fetchSettings(profile.id)
  return ok({ user, profile, settings })
})

到了這裡,能在享有型別層面錯誤的好處下也保持代碼的可讀性。我們是否已經建立起完美無缺的理想鄉呢?可惜還不行。

補充TypeScript的標準庫

2020年,基於函數式編程思想所創建的強大庫Effect問世。它包含了現代Web應用開發所需的重要元件。

順便提一句,我知道Effect的契機是在2023年fp-ts的創作者加入Effect小組。[^6]

這次我將重點講解錯誤處理,但首先介紹Effect的基本概念。

Effect的基本

Effect的型別是Effect<A, E = never, R = never>,其型別參數定義如下。

  • 成功型 A
    此Effect執行成功時返回的值的型別。
  • 錯誤型 E
    此Effect執行失敗時返回的錯誤的型別。
  • 所需依賴 R
    此Effect執行所需的依賴的型別。此次不予討論。
import { Effect } from "effect"

type FetchUserEffect = Effect.Effect<User, FetchUserError>

const fetchUser = (id: string): FetchUserEffect =>
  Effect.tryPromise({
    try: () => fetch(`/api/users/${id}`).then((r) => r.json()),
    catch: (error) => ({
      type: "NetworkError" as const,
      message: String(error),
    }),
  })

Effect是一個程式的雛形,直到執行之前沒有副作用。

// 只是個雛形
const program = fetchUser("123")

// 執行以進行實際的fetch
await Effect.runPromise(program)

組合多個可能失敗的Effect可以使用mapandThen

function fetchUser(id: string): Effect.Effect<User, Error>
function fetchProfile(id: string): Effect.Effect<Profile, Error>
function fetchAvatar(id: string): Effect.Effect<Avatar, Error>

const userDetail = fetchUser("123").pipe(
  Effect.andThen((user) =>
    fetchProfile(user.profileId).pipe(
      Effect.andThen((profile) =>
        fetchAvatar(profile.avatarId).pipe(
          Effect.andThen((avatar) =>
            Effect.succeed({ user, profile, avatar })
          )
        )
      )
    )
  )
)

使用讓書寫方式像async/await的Effect.gen

const userDetail = Effect.gen(function* () {
  const user = yield* fetchUser("123")
  const profile = yield* fetchProfile(user.id)
  const settings = yield* fetchSettings(profile.id)
  return { user, profile, settings }
})

咦❓neverthrow跟這個不是差不多嗎❗️

不一樣。其實…

兩種類型的錯誤

Effect將程式失敗的可能性分為兩類。

  • 預期內的錯誤
    開發者事先預測,並認為是在正常程式執行的一部分中發生的錯誤。
  • 預期外的錯誤
    與設計的程式流程不符,並且無法預測的錯誤。

預期內的錯誤會通過Effect的錯誤型別在型別層面追蹤,這部份與neverthrow的Result相同。

另一方面,預期外的錯誤不被neverthrow處理,而是由Effect在執行時處理。不同之處在於,透過Cause<E>這個資料型別,不僅能夠保留預期內的錯誤資訊,還能保持有關以下各種失敗的所有資訊

  • 預期外的錯誤與缺陷
  • 堆疊追蹤
  • fiber的中斷原因

由於牽涉到Effect的執行環境,因此這裡不會詳細說明,不過在官方文檔中有一個簡單的例子供參考。

import { Effect, Console, Cause } from "effect"

const task = (input: string) =>
  input === "ok"
    ? Effect.succeed("success!")
    : input === "fail"
    ? Effect.fail("expected failure")
    : Effect.die(new Error("unexpected defect"))

const program = (input: string) =>
  Effect.matchCauseEffect(task(input), {
    onFailure: (cause) => {
      switch (cause._tag) {
        case "Fail":
          return Console.log(`fail: ${cause.error}`)
        case "Die":
          return Console.log(`die: ${Cause.pretty(cause)}`)
        case "Interrupt":
          return Console.log(`${cause.fiberId} interrupted!`)
      }
      return Console.log("failed due to other causes")
    },
    onSuccess: (value) => Console.log(`succeeded with ${value} value`),
  })

Effect.runPromise(program("ok"))
// succeeded with success! value

Effect.runPromise(program("fail"))
// fail: expected failure

Effect.runPromise(program("boom"))
// die: Error: unexpected defect
//    at task (/***/dist/main.js:8:31)
//    at program (/***/dist/main.js:9:62)

總結

TypeScript的錯誤處理經歷了漫長的演變之路。

問:JavaScript的null與undefined問題
答:通過TypeScript進行型別層面的null/undefined檢查
問:在型別系統外的try/catch
答:通過neverthrow實現型別安全的錯誤處理
問:預期內與預期外錯誤的區分
答:通過Effect實現型別安全且全面的錯誤處理

錯誤處理是一個略顯單調的主題,但卻是構建高可靠性軟體的基礎。希望你能嘗試適合你項目的方法!

[^1]: Tony Hoare - Null References: The Billion Dollar Mistake
[^2]: Suggestion: throws clause and typed catch clause · Issue #13219 · microsoft/TypeScript
[^3]: Type-Safe Error Handling In TypeScript
[^4]: TypeScript 函數型風格的後端開發的真實
[^5]: TypeScript: Documentation - TypeScript 3.6
[^6]: A bright future for Effect


原文出處:https://qiita.com/touka-io/items/2a75c0558e3b206c7433


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝17   💬10   ❤️5
425
🥈
我愛JS
📝2   💬8   ❤️4
91
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付