🐙

KotlinでUnion Typeを使いこなそう

2024/05/27に公開

はじめに

こんにちは、株式会社ログラスでエンジニアをしています、Kyosukeです。
私は前職では主にTypescriptでフロントエンド・バックエンドのどちらも開発を行っていたのですが、ログラスではフロントエンドにTypescript、バックエンドにKotlinを使用しています。
そしてKotlinを書いていると、定期的にTypeScriptで言う、Union Typeを使いたくなることがあります。
そこで、この記事では、TypeScriptでお馴染みのUnion TypeをKotlinでどう扱うかを探っていきます。

TypeScriptでのUnion Type

まずはTypeScriptにおけるUnionTypeについて復習しましょう。
TypeScriptでは、異なる型を一つの変数で扱いたくなる場合にUnion Typeを使います。
これは複数の型のうちの一つを受け入れることができるという意味で、| 演算子を用いて型を結合することができます。

サンプルコード

以下の例では、SuccessErrorという2つの異なる型をResultというUnion Typeで表現しています。

interface Success {
  type: 'success';
  message: string;
}

interface MyError {
  type: 'error';
  error: string;
}

type Result = Success | MyError;

function handleResult(result: Result) {
  switch (result.type) {
    case 'success':
      console.log(`Success: ${result.message}`);
      break
    case 'error':
      console.error(`Error: ${result.error}`);
      break;
  }
}

// 使用例
handleResult({ type: 'success', message: '操作が成功しました。' });
handleResult({ type: 'error', error: '何らかのエラーが発生しました。' });

このTypeScriptのコードでは、SuccessMyErrorという2つのインターフェースを定義し、それぞれtypeプロパティを持たせています。
これにより、関数handleResult内での型チェックをtypeプロパティに基づいて行っています。

このようにしてTypeScriptではUnion Typeを使って、異なる型を柔軟に扱うことができます。

KotlinでのUnion Type

Kotlinを書いていくと、TypeScriptのように気軽にUnion Typeを使えないことに気づくかもしれません(私はそうでした)。
しかし、KotlinにはUnion Typeに相当する機能があり、それを使うことでTypeScriptのような柔軟な型の取り扱いを実現することができます。
ただし、TypeScriptのように直接的なUnion Typeをサポートしていないため、多少の工夫が必要になります。

サンプルコード

Kotlinでは、Union Typeに相当する機能を実現するためにいくつかの方法がありますが、最も一般的な方法はsealed classを利用することです。
sealed classは、限定されたサブクラスを持つことができるクラスであり、これを使うことで、TypeScriptのUnion Typeのようなパターンを模倣することが可能です。

sealed class Result
data class Success(val message: String) : Result()
data class Error(val error: String) : Result()

fun handleResult(result: Result) {
    when (result) {
        is Success -> println("Success: ${result.message}")
        is Error -> println("Error: ${result.error}")
    }
}

// 使用例
handleResult(Success("操作が成功しました。"))
handleResult(Error("何らかのエラーが発生しました。"))

このコード例では、Resultというsealed classを定義し、そのサブクラスとしてSuccessErrorという2つのデータクラスを定義しています。
関数handleResultでは、when式を使って、結果がSuccess型かError型かに応じた処理を行っています。
これにより、TypeScriptの例と同様に、異なる型に基づいたロジックの分岐を安全に実装することができます。

少し複雑なUnion Typeの取り扱い

先ほどの例はかなりシンプルな例でしたが、もう少し複雑なUnion Typeの取り扱いを考えてみましょう。

TypeScriptの例

ここでは円と四角形の面積を計算する関数を例に考えます。
この場合、CircleSquareという2つの型を持つUnion Typeを使って、円と四角形の面積を計算する関数を実装します。

type Shape = Circle | Square;
interface Circle {
    kind: "circle";
    radius: number;
}
interface Square {
    kind: "square";
    sideLength: number;
}

function getArea(shape: Shape) {
    switch (shape.kind) {
        case "circle":
            return Math.PI * shape.radius ** 2;
        case "square":
            return shape.sideLength ** 2;
    }
}

Kotlinでの対応

それでは、このTypeScriptの例をKotlinでどのように実装するか見ていきましょう。

sealed class Shape
data class Circle(val radius: Double) : Shape()
data class Square(val sideLength: Double) : Shape()

fun getArea(shape: Shape): Double =
    when (shape) {
        is Circle -> Math.PI * shape.radius * shape.radius
        is Square -> shape.sideLength * shape.sideLength
    }

実際のコードでは、より複雑なUnion Typeを扱うことになると思いますが、基本的にはこのように、sealed classwhen式を組み合わせることで、TypeScriptのUnion Typeに相当する機能をKotlinで実現することができます。

Kotlinのwhen式は、TypeScriptのswitch文とどう違うのか

Kotlinでsealed classを利用する際、when式が非常に重要な役割を果たします。
特に、Union Type的なパターンを扱うケースでは、when式の網羅性がコードの安全性と信頼性を大きく向上させます。

網羅性の担保とTypeScriptの比較

TypeScriptの例:

type PaymentStatus = "pending" | "completed" | "failed";

const handlePaymentStatus = (status: PaymentStatus) => {
  switch (status) {
    case "pending":
      console.log("支払いが処理中です。");
      break;
    case "completed":
      console.log("支払いが完了しました。");
      break;
    case "failed":
      console.log("支払いに失敗しました。");
      break;
    default:
      const exhaustiveCheck: never = status;
      break;
  }
};

このTypeScriptの例では、PaymentStatus型の網羅性をチェックしています。
defaultケースでnever型を使用しているため、新たなPaymentStatusの値を追加した場合にコンパイルエラーが発生し、網羅性が担保されます。

Kotlinでの対応:

Kotlinではsealed classを使い、when式で同様の網羅性をシンプルに担保できます。

sealed class PaymentStatus
object Pending : PaymentStatus()
object Completed : PaymentStatus()
object Failed : PaymentStatus()

fun handlePaymentStatus(status: PaymentStatus) {
    when (status) {
        is Pending -> println("支払いが処理中です。")
        is Completed -> println("支払いが完了しました。")
        is Failed -> println("支払いに失敗しました。")
        // Kotlinの`when`式は全てのケースを網羅している必要があり、
        // ここでは`else`ケースは不要です。
    }
}

when式の利点

  • 型安全性の強化: when式を使うことで、Kotlinは全てのケースが網羅されているかをコンパイル時にチェックします。これにより、未処理のケースがあればコンパイルエラーとなり、意図しない挙動やランタイムエラーを事前に防ぎます。
  • コードの明瞭さ: elseケースを記述する必要がないため、処理すべき具体的なケースが一目で明確になります。これは、コードの読みやすさと保守性を向上させる効果があります。

Kotlinのwhen式は、Union Type的なパターンを扱う際の網羅性をシンプルかつ効率的に担保する強力なツールです。
この特性を活用することで、Kotlinでの型安全性を高めつつ、読みやすく保守しやすいコードを実現できます。

おまけ: より高度なパターンマッチを求めて

実はここが一番のお気に入りセクションですので、ぜひ読んでいってください。

Kotlinのwhen式や、TypeScriptのswitch文は、パターンマッチングを行う際に非常に便利な機能ですが、時にはもう少し高度なパターンマッチングを行いたいという場面もあるかもしれません。
その場合、KotlinやTypeScriptの標準機能だけでは限界があるかもしれません。
そんな皆様におすすめしたいのが、Rustという言語です。Rustはいいぞ。

公式記事より抜粋していくつかご紹介しますので、気になる方はぜひRustを触ってみてください。

複数の変数を一度にマッチングする

これはよくあるケースではないでしょうか。
Kotlinでこれを実現するには、PairTupleを使って、いったん1つの変数にまとめる必要がありますが、Rustでは以下のように非常にシンプルに記述することができます。

fn main() {
    let hoge = 2;
    let fuga = 2;

    match (hoge, fuga) {
        (2, 2) => println!("wow!"),
        _ => println!("anything"),
    }
}

範囲条件を使う

次は、範囲条件を使ったパターンマッチングです。
Kotlinではこのような条件を使う場合、when式の中で..を使って範囲を指定する必要がありますが、Rustでは以下のように非常にシンプルに記述することができます。

fn main() {
  let x = 5;

  match x {
      // 1から5まで
      1..=5 => println!("one through five"),
      // それ以外
      _ => println!("something else"),
  }
}

マッチ条件に追加で条件をつける

最後に最強の機能を紹介します。
正直、これがあれば何でもできるのでは?と思うほどの機能ですが、用法容量は守って使っていきましょう。

これもKotlinではwhen式の中でifを使って条件を追加する必要がありますが、Rustでは以下のように非常にシンプルに記述することができます。

fn main() {
    let x = Some(5);
    let y = 10;

    match x {
        Some(50) => println!("Got 50"),
        Some(n) if n == y => println!("Matched, n = {:?}", n),
        _ => println!("Default case, x = {:?}", x),
    }

    println!("at the end: x = {:?}, y = {:?}", x, y);
}

この場合では、xSomeであり、かつその値がyと等しい場合にMatched, y = 10と出力されます。

まとめ

この記事では、TypeScriptでお馴染みのUnion TypeをKotlinでどのように扱うかを探求しました。
KotlinにはTypeScriptのようなUnion Typeが直接存在しないものの、sealed classwhen式を駆使することで、同等の柔軟性と型安全性を達成する方法を見てきました。

  • sealed classの利用: Kotlinでは、sealed classを通じて限定されたサブクラスを持つことができ、これを用いることでTypeScriptのUnion Typeと同様のパターンを表現できます。
  • when式の網羅性: when式を使ってsealed classのインスタンスを扱うことで、全てのケースがコンパイル時に網羅されていることを保証します。これは、TypeScriptのswitch文とnever型を用いた網羅性のチェックに類似していますが、Kotlinではさらに直接的にこの保証を得ることができます。
  • 型安全性の向上: これらの手法を用いることで、Kotlinの強力な型システムの恩恵を受けつつ、TypeScriptでの経験を活かすことが可能になります。また、コードの読みやすさと保守性の向上にも寄与します。

KotlinとTypeScriptは異なる言語でありながら、型の扱い方において互いから学べる点が多々あります。
TypeScriptのUnion Typeの柔軟性をKotlinで実現する方法を理解することで、より安全で保守しやすいアプリケーションの開発が可能になるでしょう。

最後までお読みいただき、ありがとうございました!

GitHubで編集を提案
株式会社ログラス テックブログ

Discussion