🚨

TypeScriptでエラーを型で表現できるライブラリを作ってみた

2024/12/04に公開

はじめに

この記事では例外処理をきちんと行うためのやり方として例外をリターンする方法について説明し、それを支援するための自作ライブラリ「tagged-error」について紹介します。またその実装方法についても解説します。

https://github.com/nakanoasaservice/tagged-error

https://www.npmjs.com/package/@nakanoaas/tagged-error

皆さん例外処理ちゃんとやってますか?

例外が発生するかもしれない関数を呼び出すとき、ついついtry catchをサボってしまうことってよくありますよね?

例: トークンの中身をパースして結果を返す関数(不正なトークンなら例外)

function verifyToken(token: string): UserSessionInfo {
  if (!isTokenValid(token)) {
    throw new Error("不正なトークンです")
  }

  const payload = parseToken(token)

  return payload
}

呼び出しの例:

const session = verifyToken(cookie().token)

/* 不正なトークンが渡ってきた場合が考慮できてない! */

const user = getUser(session.userId) // ここまでたどり着かない

そんなあなたにおすすめのやり方があります!

例外をリターンする

次のように例外をスローするのではなく return してみましょう。

function verifyToken(token: string): UserSessionInfo | Error {
  if (!isTokenValid(token)) {
    // 例外をリターンするようにした
    return new Error("不正なトークンです")
  }

  const payload = parseToken(token)

  return payload
}

この返り値の|はユニオン型といって、返り値がUserSessionInfoErrorのどちらかであることを表しています。

このように例外をリターンすることで次の2つのメリットが生まれます

メリット1: 例外処理をしないと値を使えなくなる

この関数の結果をそのまま利用しようとすると、次のような型エラーが発生します。

これを回避するには次のように返り値が Error である可能性を消すしかありません。

const session = verifyToken(cookie().token)

if (session instanceof Error) {
  // エラーだったらログインページにリダイレクト
  redirect("/login")
  return
}

// sessionがErrorである可能性は消えたのでuserIdが使える!
const user = getUser(session.userId)

このようにして呼び出し側に例外処理の実装を強制できます。

メリット2: どんなエラーが起こるのかわかる

次のように独自の例外クラスを定義しておけば、関数の返り値の型を見るだけでどんなエラーが起こるのかわかります。

// 独自の例外クラスを定義
class InvalidTokenError extends Error {}
class InvalidPayloadError extends Error {}

// 関数の返り値の型を見ればどんなエラーが起こるかわかる
function verifyToken(token: string):
  | UserSessionInfo
  | InvalidTokenError
  | InvalidPayloadError {
  if (!isTokenValid(token)) {
    return new InvalidTokenError()
  }

  // 先の例に加えてトークンのパース時にも例外が発生することを考慮
  const payload = parseToken(token)
  if (payload instanceof Error) {
    return new InvalidPayloadError()
  }
}

この例だと返り値の型からエラーの理由が不正なトークンによるものなのか、ペイロードの中身によるものなのかが区別できます。

const session = verifyToken(cookie().token)

if (session instanceof InvalidTokenError) {
  // sessionはInvalidTokenErrorとして扱われる
  redirect("/login")
  return
}

if (session instanceof InvalidTokenError) {
  // sessionはInvalidPayloadErrorとして扱われる
  redirect("/login")
  return
}

// sessionがInvalidTokenErrorまたはInvalidTokenErrorである
// 可能性は消えたのでuserIdが使える!
const user = getUser(session.userId)

一方で独自の例外クラスを定義する必要が出てきました。

独自例外クラスの定義を不要にするライブラリを作った

そこで独自例外クラスを不要にするライブラリを作成しました!

https://github.com/nakanoasaservice/tagged-error

このライブラリを用いると、先程の関数は次のように書くことができます。

import { TaggedError } from "@nakanoaas/tagged-error"

function verifyToken(token: string):
  | UserSessionInfo
  | TaggedError<"INVALID_TOKEN">
  | TaggedError<"INVALID_PAYLOAD"> {
  if (!isTokenValid(token)) {
    return new TaggedError("INVALID_TOKEN")
  }

  const payload = parseToken(token)
  if (payload instanceof Error) {
    return new TaggedError("INVALID_PAYLOAD")
  }

  return payload
}

この TaggedError が私が定義した唯一の例外クラスです!

そして例外処理は次のように変わります。

const session = verifyToken(cookie().token)

if (session instanceof TaggedError) {
  if (session.tag === "INVALID_TOKEN") {
    // TaggedError<"INVALID_TOKEN"> 型として扱われる
    redirect("/login")
    return
  }

  if (session.tag === "INVALID_PAYLOAD") {
    // TaggedError<"INVALID_TOKEN"> 型として扱われる  
    redirect("/login")
    return
  }
  ...
}

// sessionがTaggedError<"INVALID_TOKEN">またはTaggedError<"INVALID_TOKEN">である
// 可能性は消えたので、以降sessionはUserSessionInfoとして扱われる

...

このように独自例外を用いなくても2種類のエラーが起きうることを型で表現できるようになりました!

例外に追加の情報を加えたい

独自例外を用いると例外の原因となった情報を次のように付加できます。

class InvalidTokenError extends Error {
  // 原因となったトークン
  causedToken: string

  constructor(message: string, token: string) {
    super(message)
    this.causedToken = token
  }
}
class InvalidPayloadError extends Error {}

// 関数の返り値の型を見ればどんなエラーが起こるかわかる
function verifyToken(token: string):
  | UserSessionInfo
  | InvalidTokenError
  | InvalidPayloadError {
  if (!isTokenValid(token)) {
    return new InvalidTokenError("不正なトークンです", token)
  }

  // 先の例に加えてトークンのパース時にも例外が発生することを考慮
  const payload = parseToken(token)
  if (payload instanceof Error) {
    return new InvalidPayloadError()
  }
}
const session = verifyToken(cookie().token)

if (session instanceof InvalidTokenError) {
  // 呼び出し側でエラーの原因となったトークンを取得できる
  const causedToken = session.causedToken

  ...
}

この独自例外クラスの実装を見てわかるように、コンストラクタなども独自に定義しなければならず大変です。

私の作成したTaggedErrorを使えば次のように書くことができます。

function verifyToken(token: string):
  | UserSessionInfo
  | TaggedError<"INVALID_TOKEN", string>
  | TaggedError<"INVALID_PAYLOAD"> {
  if (!isTokenValid(token)) {
    return new TaggedError("INVALID_TOKEN", {
      message: "不正なトークンです",
      cause: token  // この`cause`に原因となったトークンを入れる
    })
  }

  const payload = parseToken(token)
  if (payload instanceof TaggedError) {
    return new TaggedError("INVALID_PAYLOAD")
  }
  return payload
}

TaggedErrorの第2引数のcauseに原因となったトークンを渡すことができます。

そしてこれにアクセスするには次のようにします。

const session = verifyToken(cookie().token)

if (session instanceof TaggedError) {
  if (session.tag === "INVALID_TOKEN") {
    // TaggedError<"INVALID_TOKEN"> 型として扱われる
    const token = session.cause // causeに原因となったtoken(string)が入っている
  }
  ...
}

このようにして例外に情報を付加することを独自例外よりもはるかにシンプルに実装できます!

どうやってこれを実現しているのか

この TaggedError はタグ付きユニオン型(判別可能なユニオン型)の応用です。

タグ付きユニオンでエラーを表現する

先程の型を素朴にタグ付きユニオンを用いたインターフェイスで表現すると、以下のようになります。

interface InvalidTokenError {
  tag: "INVALID_TOKEN"
  cause: string
}

interface InvalidPayloadError {
  tag: "INVALID_PAYLOAD"
  cause: undefined
}
let error: InvalidTokenError | InvalidPayloadError

if (error.tag === "INVALID_TOKEN") {
   // errorはInvalidTokenError型として扱われる
   error.cause  // cause(string)にアクセスできる
}

if (error.tag === "INVALID_PAYLOAD") {
   // errorはInvalidPayloadError型として扱われる
   error.cause  // causeはundefined
}

ジェネリクスで汎用的にする

これらのインターフェイスはジェネリクスを用いて次のように汎用的に定義できます。

interface TaggedError<T extends string, C> {
  tag: T
  cause: C
}
let error: TaggedError<"INVALID_TOKEN", string> | TaggedError<"INVALID_PAYLOAD">

if (error.tag === "INVALID_TOKEN") {
   // errorはInvalidTokenError型として扱われる
   error.cause  // cause(string)にアクセスできる
}

if (error.tag === "INVALID_PAYLOAD") {
   // errorはInvalidPayloadError型として扱われる
   error.cause  // causeはundefined
}

例外クラスに応用する

これを例外クラスに応用してできたのが TaggedError です。以下が TaggedError の実装のすべてです。

interface TaggedErrorOptions<Cause = never> {
  message?: string;
  cause?: Cause;
}

export class TaggedError<Tag extends string, Cause = never> extends Error {
  tag: Tag;
  override cause: Cause;

  constructor(
    tag: Tag,
    options: TaggedErrorOptions<Cause> | undefined = undefined,
  ) {
    super(options?.message);

    this.tag = tag;

    if (options?.cause) {
      this.cause = options.cause;
    }

    this.name = `TaggedError('${tag}')`;
  }
}
YOSHINANI

Discussion