2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

TypeScriptで「嘘をつけない分岐」をつくる(discriminated union)

Posted at

はじめに

TypeScript で開発していると、構造がほぼ同じだけど意味の異なる型を扱う場面に遭遇することがあります。

たとえば、次のような値を扱うケースです。

  • 受領文書(ReceivedDocument)
  • 作成文書(CreatedDocument)

これらには似たようなプロパティ(例:タイトル、本文、コメントなど)が含まれていて構造もほとんど同じです。
なので、同じ関数や hooks で扱いたい一方、受領文書か作成文書かによって処理を分岐させる必要があり、どう設計するか悩んだときのおはなしです。

このような状況でとれるアプローチとして、

  • 単なるフラグを使う方法
  • discriminated union(識別可能な共用体)を使う方法

を比較し、それぞれのメリット・デメリットを整理してみました。

今回やりたかったこと

次のような usePostComment というコメント投稿用の hooks を作成しようとしました。

export const usePostComment = <T extends { comments?: Comment[] }>(params: {
    data: T;
}) => {
    // data が ReceivedDocument か CreatedDocument かで処理を分けたい
};

T にとりうる型は CreatedDocumentReceivedDocument のどちらかです。
そして、hooks 内で、dataCreatedDocumentReceivedDocument かによって処理を分岐させたい(具体的には、呼び出す API のエンドポイントを切り替えたい)と考えました。

前提:TypeScript の型は実行時に存在しない

TypeScript の型はコンパイル時にのみ存在し、実行時には存在しません。
そのため、型にもとづいて実行時に処理を分岐させることはできません。
例えば、以下のようなコードはコンパイルエラーになります。
※ ここでの CreatedDocumentReceivedDocument は type または interface として定義されているものとします。

if (data instanceof CreatedDocument) {
  // エラー: 'CreatedDocument' は型のみを参照しますが、ここで値として使用されています
}   else if (data instanceof ReceivedDocument) {
  // エラー: 'ReceivedDocument' は型のみを参照しますが、ここで値として使用されています
}

そのため「型で分岐させたい」となったら、型を値として区別できる情報を持たせる必要があります。

よくある解決策:フラグを渡す

最初に考えたのは、フラグを使って分岐させる方法です。
例えば、以下のように isReceived というフラグを追加します。

export const usePostComment = <T extends { comments?: Comment[] }>(params: {
    data: T;
    isReceived: boolean;
}) => {
    if (params.isReceived) {
        // ReceivedDocument 用の処理
    } else {
        // CreatedDocument 用の処理
    }
};

ぱっと見これでもよさそうにみえますが、いくつか問題点に気づきました。

問題点①:嘘をつけてしまう

usePostComment<CreatedDocument>({
    data: createdDoc,
    isReceived: true    // ダウト!
});

このように data の型と isReceived フラグの値が矛盾するコードを書くことができます。
(上の例だと、dataCreatedDocument であるにもかかわらず、isReceived フラグが true になっています)

つまり「嘘をつける」設計になってしまっています。

問題点②:if文で型が絞れない

if (params.isReceived) {
    // ここで data の型は ReceivedDocument とは限らない
    const receivedData = params.data as ReceivedDocument;
}

isReceived は型ガードではないので、if 文の中で data の型を ReceivedDocument に絞り込むことはできません。
そのため、if 文の中で data を使うときに as 演算子で型アサーションを行う必要があります。
これは型安全性を損なう原因となります。

解決策:discriminant を「外から」渡す(discriminated union を使う)

この問題を解決するために有効なのが、discriminated union(識別可能な共用体)を使う方法です。
discriminated union を使うことで、型に基づいて処理を分岐させることができます。
discriminated union では、各型を判別するために共通の discriminant(識別子)を持たせます。

例えば、以下のように isReceived プロパティを識別子として使用します。

type CommentTarget = 
  | { isReceived: false; data: CreatedDocument }
  | { isReceived: true; data: ReceivedDocument };

discriminant に使えるのは、主に リテラル型(文字列・数値・真偽値) です。
nullundefined を使うこともできますが、分岐が明確になり型ガードとして扱いやすいため、実際には文字列を使うケースが多いようです。

usePostComment 側の実装例

export const usePostComment = (params: { target: CommentTarget }) => {
    // 例えば、API エンドポイントを切り替えることも可能
    const endpoint = params.target.isReceived
        ? "/api/received-document/comment"
        : "/api/created-document/comment";

    // 型ガードとしても機能する
    if (params.target.isReceived) {
        // target は ReceivedDocument 型として扱える
        const receivedData = params.target.data;
    } else {
        // target は CreatedDocument 型として扱える
        const createdData = params.target.data;
    }
};

ここでは dataisReceived を別々に渡すのではなく、「コメント対象そのもの」を target としてひとつの型にまとめています。
これにより、受領文書か作成文書かという ドメイン上の違い を型で表現できます。

呼び出し側の実装例

// OK
usePostComment({
    target: { isReceived: true, data: receivedDoc },
});

// NG: isReceived と data が矛盾しているためコンパイルエラーになる
usePostComment({
    target: { isReceived: true, data: createdDoc }, // エラー: 型 'CreatedDocument' は型 'ReceivedDocument' に割り当てることはできません
});

discriminant と フラグの比較

discriminant(discriminated union の識別子)とフラグには以下のような違いがあります。

観点 discriminant フラグ
data との関係 型で結びつく 型上は無関係
嘘をつけるか つけない つける
型ガードとして機能するか する しない
保守性(可読性) 高い 低い

設計的な違い

  • discriminant は ドメイン情報
    • discriminant は型の一部であり、TypeScript に型情報を提供する
  • フラグは 実装都合の分岐指示
    • フラグは単なる真偽値であり、型情報を持たない

ドメイン情報とは、そのデータが持つ本質的な性質や属性を指します。
discriminant は単に分岐したいからつけた値ではなくて、そのデータが何者かを示す重要な情報として設計されます。
たとえば、isReceived という discriminant は、そのデータが受領文書であるか否かを示す重要な情報です。
一方、フラグは単に処理を分岐させるための手段であり、そのデータの本質的な性質を表すものではありません。

じゃあ、フラグはいつ使うの?

つぎのようなケースでフラグを使うのはありです。

  • 一時的な実装やプロトタイプの場合(素早く実装したいとき)
  • 影響範囲が限定されている場合(小さなモジュール内でのみ使うとき)
  • どうしても型を変えられない場合(外部ライブラリの型など)

けど、基本的には discriminated union のほうが型安全で保守性も高いのでおすすめです。

まとめ

見た目が似ていても、discriminant とフラグは別物でした。

  • discriminant は型の一部として機能し、嘘をつけない 設計
  • フラグは単なる真偽値で型情報を提供しないので、呼び出し側が正しく使っていることを信じる 設計

「型で分岐したくなったら、まずは discriminated union を検討する」
これを覚えておくとしあわせになれるはずです。

おわりに

今回ふとした疑問から discriminated union の存在を知りました。(ちゃんと勉強しとけよ、というつっこみはなしで…)
discriminated union を使うことで型安全性が向上し、見通しのよいコードが書けることを実感しました。
いままで何も考えずにフラグを使っていたような気がするので、今後は意識的に discriminated union を活用していきたいです。

参考リンク

2
0
0

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
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?