🏷️

型安全に条件分岐できてない?判別可能なユニオン型で解決!

2024/08/18に公開

こんにちは。ソフトウェアエンジニア(志望の26卒)の naotani です!
大学ではヨーロッパの歴史を学びつつ、課外ではwebアプリケーションの開発(主にフロントエンド)に取り組んでいます。

実は今回、初めてzennで記事を執筆しています…!
これからも情報発信活動に力を入れていこうと思っています🔥

はじめに

突然ですが、TypeScriptで条件分岐や絞り込みをするとき、

hogehogeはundefinedの可能性があります。
型hogehogeを型foobarに割り当てることはできません。

的なエラーメッセージを見たことがあると思います。

そして、そのエラーを解消しようとして型定義を修正していくと、型が冗長になったり、アクセスするときに ? で繋がないといけないハメになっちゃうことがよくあります。

今回はそんなTypeScriptのよくある悩みを解決する判別可能なユニオン型を紹介します!

判別可能なユニオン型を使わない絞り込み

具体的なコードを例に挙げてみていきましょう。

 
アラビア数字とローマ数字を相互に変換するWebアプリケーションを実装します。
ローマ数字はI, M, Xのようにアルファベットなので、どちらが来てもいいように文字列で入力を受け取ります。
処理が成功したらアラビア数字やローマ数字を表す文字列を返し、失敗すればフロントでエラーメッセージを表示させるようにします。

型定義

まず、変換処理を行う関数の返り値を定義します。
resulterrorMessageは常に存在するわけではないのでオプショナルとしています。

convert.ts
type ProcessInputResult = {
  status: "success" | "failure" | "waiting";
  result?: string;
  errorMessage?: string;
};

 
この型を返り値にもつ関数を定義します。
 

関数定義

convert.ts
export function processInput(input: string): ProcessInputResult {
  let errorMessage: string | null = null;

  // 入力値が無い/空白改行のみの状態
  if (input.trim() === "") {
    return {
      status: "waiting",
    };
  }

  const inputType = determineInputType(input); // アラビア・ローマ・その他を判断する

  // 入力のタイプによって各々の処理を走らせる
  switch (inputType) {
    case "arabic":
      const arabic = parseInt(input, 10);
      if (arabic > 4000) {
        errorMessage = "4001以上のアラビア数字は変換できません。";
        return {
          status: "failure",
          errorMessage: errorMessage,
        };
      }
      if (arabic <= 0) {
        errorMessage = "0以下の数字は変換できません。";
        return {
          status: "failure",
          errorMessage: errorMessage,
        };
      }
      return {
        status: "success",
        result: arabicToRoman(arabic),
      };
    case "roman":
      try {
        return {
          status: "success",
          result: romanToArabic(input),
        };
      } catch (error) {
        errorMessage = "無効なローマ数字です。";
        return {
          status: "failure",
          errorMessage: errorMessage,
        };
      }
    case "other":
      errorMessage = "有効なアラビア数字またはローマ数字ではありません。";
      return {
        status: "failure",
        errorMessage: errorMessage,
      };
  }
}

このとき、エラーの分岐が少々厄介なことに気づきます。

  • 入力値がローマ・アラビア数字でもない場合
  • 入力値がアラビア数字で1~4000以外の値の場合
  • 入力値がローマ数字っぽいが不適切な場合
  • 何らかのエラー

分岐がややこしいし、それぞれの結果に対してオブジェクトで返しているので若干コードが読みづらくなっています。

コンポーネント定義

コンポーネントで呼び出す箇所も確認してみましょう。

page.tsx
const Example = () => {
  const [input, setInput] = useState("");
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setInput(e.target.value);
  };

  const status = processInput(input);
  return (
    <div className="mx-auto flex max-w-[640px] flex-col gap-4 p-8 lg:p-16">
      <Label htmlFor="convert">アラビア数字・ローマ数字を入力</Label>
      <Input type="text" id="convert" value={input} onChange={handleChange} />
      {status.status === "failure" && (
        <Alert variant="destructive">
          <AlertCircle className="h-4 w-4" />
          <AlertTitle>エラー</AlertTitle>
          <AlertDescription>{status.errorMessage}</AlertDescription>
        </Alert>
      )}
      {status.status === "success" && (
        <Alert variant="success">
          <CircleCheck className="h-4 w-4" />
          <AlertTitle>結果</AlertTitle>
          <AlertDescription className="flex items-end justify-between">
            <p className="text-xl">{status.result}</p>
            <CopyBtn text={status.result}/>
          </AlertDescription>
        </Alert>
      )}
      <div className="">
        <h3 className="mb-2 font-bold">注意</h3>
        <div className="flex flex-col gap-y-1">
          <p>アラビア数字は1以上9999以下の整数を入力してください。</p>
          <p>ローマ数字の出入力は大文字のアルファベットで行います。</p>
        </div>
      </div>
    </div>
  );
};

processInput関数の返り値のresultやerrorMessageはオプショナルなので、以下画像のように指摘されてしまいます。

resultがundefinedになる可能性を指摘されている

演算子??undefinedの場合を記述すればエラーを回避することはできますが、なんだかスマートでない気がします。

上記のような悩みを解決するのが、判別可能なユニオン型(discriminated union)です!

判別可能なユニオン型を使った絞り込み

判別可能なユニオン型とは?

判別可能なユニオン型とは、複数の型を1つの型にまとめ、それぞれの型に識別子を持たせることで、型の安全性を高める仕組みです。
具体的には、各型に共通のプロパティ(statusなど)を持たせ、そのプロパティの値で型を区別します。これにより、コード内で型を絞り込むことができ、型推論を活用して安全に処理を行うことが可能です。
判別可能なユニオン型は条件分岐やパターンマッチングで有効に機能し、TypeScriptの強力な型安全性をサポートします。

型定義

convert.ts
type ProcessInputStatus = Success | Failure | Waiting;
type Success = {
  status: "success";
  result: string;
};
type Failure = {
  status: "failure";
  errorMessage: string;
};
type Waiting = {
  status: "waiting";
};

ProcessInputStatusは、SuccessFailureWaitingからなるユニオン型です。
通常のユニオン型と違う点は、各型に共通のstatusプロパティが存在することです。
このように、各型に共通のプロパティを用意することで、条件分岐が容易になります。

関数定義

判別可能なユニオン型を使って関数を定義します。
先ほど定義した関数と特に変わりはありません。

convert.ts
export function processInput(input: string): ProcessInputStatus {
  if (input.trim() === "") {
    return {
      status: "waiting",
    };
  }
  const inputType = determineInputType(input);

  switch (inputType) {
    case "arabic":
      const arabic = parseInt(input, 10);
      if (arabic > 4000) {
        return {
          status: "failure",
          errorMessage: "4001以上のアラビア数字は変換できません。",
        };
      }
      if (arabic <= 0) {
        return {
          status: "failure",
          errorMessage: "0以下の数字は変換できません。",
        };
      }
      return {
        status: "success",
        result: arabicToRoman(arabic),
      };
    case "roman":
      try {
        const arabicResult = romanToArabic(input);
        return {
          status: "success",
          result: arabicResult,
        };
      } catch (error) {
        return {
          status: "failure",
          errorMessage: "無効なローマ数字です。",
        };
      }
    case "other":
      return {
        status: "failure",
        errorMessage: "有効なアラビア数字またはローマ数字ではありません。",
      };
  }
}

コンポーネント定義

コンポーネント側も見ていきましょう。

page.tsx
const ConvertRomanInt = () => {
  const [input, setInput] = useState("");
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setInput(e.target.value);
  };

  const status = processInput(input);
  return (
    <div className="mx-auto flex max-w-[640px] flex-col gap-4 p-8 lg:p-16">
      <Label htmlFor="convert">アラビア数字・ローマ数字を入力</Label>
      <Input type="text" id="convert" value={input} onChange={handleChange} />
      {status.status === "failure" && (
        <Alert variant="destructive">
          <AlertCircle className="h-4 w-4" />
          <AlertTitle>エラー</AlertTitle>
          <AlertDescription>{status.errorMessage}</AlertDescription>
        </Alert>
      )}
      {status.status === "success" && (
        <Alert variant="success">
          <CircleCheck className="h-4 w-4" />
          <AlertTitle>結果</AlertTitle>
          <AlertDescription className="flex items-end justify-between">
            <p className="text-xl">{status.result}</p>
            <CopyBtn text={status.result} />
          </AlertDescription>
        </Alert>
      )}
      <div className="">
        <h3 className="mb-2 font-bold">注意</h3>
        <div className="flex flex-col gap-y-1">
          <p>アラビア数字は1以上4000以下の整数を入力してください。</p>
          <p>ローマ数字の出入力は大文字のアルファベットで行います。</p>
        </div>
      </div>
    </div>
  );
};

この実装では、下記画像のようにしっかりとstatusプロパティによる絞り込みがなされています。
resultが型安全になっている

さいごに

判別可能なユニオン型を使うことで、複雑な状態や結果を型安全かつ明確に表現できます。
これにより、コードの品質と保守性が向上し、バグの発生リスクを軽減できます。特に非同期処理や状態管理が必要なアプリケーションで有用です。

記事で使用したローマ数字/アラビア数字変換アプリケーションを紹介しておきます。

参考資料

TypeScript 公式ドキュメント
サバイバルTypeScript

Discussion