你好,我是對函數式編程情有獨鍾的全端工程師トウカ。因為這是我第一次參加聖誕倒數日曆,能夠受到大家的溫暖目光閱讀,我非常感激。
大家有沒有過這樣的經歷?在生產環境中,應用程式突然崩潰。查看日誌時發現 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)的地方,導致運行時錯誤。如果這裡有類型檢查,則會產生編譯錯誤,就不會導致這樣的生產故障😌

</details>
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開發的項目規模不斷增大,為了應對這一問題,當時出現了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時,可以使用map或andThen等方法。
不過,實際的邏輯因為複雜,可能使代碼變得冗長而難以閱讀。[^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 })
})
到了這裡,能在享有型別層面錯誤的好處下也保持代碼的可讀性。我們是否已經建立起完美無缺的理想鄉呢?可惜還不行。
2020年,基於函數式編程思想所創建的強大庫Effect問世。它包含了現代Web應用開發所需的重要元件。
順便提一句,我知道Effect的契機是在2023年fp-ts的創作者加入Effect小組。[^6]
這次我將重點講解錯誤處理,但首先介紹Effect的基本概念。
Effect的型別是Effect<A, E = never, R = never>,其型別參數定義如下。
AERimport { 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可以使用map或andThen,
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>這個資料型別,不僅能夠保留預期內的錯誤資訊,還能保持有關以下各種失敗的所有資訊:
由於牽涉到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 ↩