171
88

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

こんにちは、関数型プログラミングを嗜んでいるフルスタックエンジニアのトウカです。アドベントカレンダー初参加なので、温かい目で読んでいただけると幸いです。

はじめに

皆さんはこんな経験ありませんか?本番環境で突然アプリがクラッシュ。ログを見ると 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では実行時エラーになり、テストでカバーするしかありません。

【番外編】型安全の重要性🔥

ほんの一例ですが、ちょうどこの記事を執筆していた先日、Qiitaに下書きページが正しく表示されない、という障害が発生しました。

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

早速Chrome DevToolsで調査し、ローカルで回避しました。見た目の原因としてはロケール(ja-JPなど)を入れるところに間違ってタイムゾーン(Asia/Tokyoなど)を入れてしまい、実行時エラーになりました。もしここに型チェックがあれば、コンパイルエラーになるので、このような本番障害にはなりませんね😌

image.png

nullとundefinedという二重の罠

Tony Hoareはプログラマの便利性を考え、nullを発明しましたが、それを後悔して10 億ドルの失敗と呼びました。1しかし、JavaScriptはこの問題をさらに悪化させ、二つ目のnullことundefinedを作り出しました。

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

const obj = {}
console.log(obj.bar) // 存在しないプロパティ、undefined

let fn // 未初期化の変数、undefined
fn()

エンジニアの皆さんは壊れたウェブサイトでChrome DevToolsを開いたとき、何度もこのような光景を見たことでしょう。

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ブロックのerrorunknown型になってしまいます。

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に導入しました。3Rustに触れることがある方にはおなじみの型ですね。

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
また、mapandThenなど関数型な書き方に慣れていない方にとっても大変です。

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ではプログラムが失敗する可能性について次の2つに分類します。

想定内のエラー
開発者が事前に想定し、通常のプログラム実行の一部として発生すると考えているエラー。
想定外のエラー
意図されたプログラムの流れとは異なり、予測できずに発生するエラー。

想定内のエラーはneverthrowのResultと同じように、Effectのエラー型により型レベルで追跡します。

一方、想定外のエラーは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のエラーハンドリングは、長い道のりを経て進化してきました。

Q: JavaScriptのnullとundefinedの問題
A: TypeScriptによる型レベルでのnull/undefinedチェック
Q: 型システムの外にあるtry/catch
A: neverthrowによる型安全なエラーハンドリング
Q: 想定内のエラーと想定外のエラーの区別
A: 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

171
88
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
171
88

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?