前回TypeScriptでやった「タグ付きユニオンのタグを指定して、型安全に値を取り出したい」をJavaでやってみた

前回書いたこれを同僚に「こんな感じだったよ。勉強になったよー!」って見せたら「なるほど勉強になった!ありがとう!」って言ってもらえたのでよかった。

bufferings.hatenablog.com

そしてその同僚が「こういうのってJavaだったらどう書くの?」って言って「んー。最近Java書いてないからどうだっけなぁ・・・そもそもJavaのときには、Discriminated Unionみたいなの欲しいと思ったことなかった気がするなぁ」ってなったのでJavaで考えてみることにした。

前回の記事のTypeScriptのコード

type MyUnion =
    | { tag: "a", value: boolean }
    | { tag: "b", value: string }

const values: MyUnion[] = [
    { tag: "a", value: true },
    { tag: "b", value: "foo" },
]

type MyMapping = {
    [I in MyUnion as I["tag"]]: I["value"]
}

function getValue<T extends MyUnion["tag"]>(t: T): MyMapping[T] {
    for (const v of values) {
        if (v.tag === t) {
            return v.value as MyMapping[T];
        }
    }
    throw new Error("Unknown tag.");
}

このコードでやりたいことは「タグ付きユニオンのタグを指定して、型安全に値を取り出したい」ということ。結局最後はType Assertionを使ってしまった。

Javaで考えてみる

だいぶ久しぶりのJava

だいぶ久しぶりにJavaを触ったので思い出したりしながら考える。もうバージョン23かー!

そもそもを考える

そもそも「タグ付きユニオンのタグを指定して、型安全に値を取り出したい」をJavaで考えるとどうなるっけ?

  • 「タグ付きユニオン」→「インターフェースを実装したクラス」で表現しそう。
  • ってことはTSのときに"a""b"を文字列ではなく型として渡してた部分はA.classB.classでいいか。
  • つまり「インターフェースの実装を持ったリストからA.classB.classを指定して値を型安全に取り出す」ってことかなぁ。

と思いながらこんなコードを書いた

hello.java

sealed interface Mapping<T> {
  T value();

  record A(Boolean value) implements Mapping<Boolean> {}

  record B(String value) implements Mapping<String> {}
}

<T> Optional<T> getValue(
    List<? extends Mapping<?>> values,
    Class<? extends Mapping<T>> clazz) {
  return values.stream()
      .filter(clazz::isInstance)
      .map(clazz::cast)
      .findFirst()
      .map(Mapping::value);
}

void main() {
  var values = Arrays.asList(
      new Mapping.A(true),
      new Mapping.B("foo")
  );

  var a = getValue(values, Mapping.A.class);
  println(a.orElseThrow());

  var b = getValue(values, Mapping.B.class);
  println(b.orElseThrow());
}

これでaOptional<Boolean>で、bOptional<String>として取り出せている。

実行するとこうなる

トップレベルでmainを定義したり、System.outを書かずにprintlnを使ったり、などプレビューフィーチャーも勉強がてら使っているので --enable-preview をつけて実行。

❯ java --enable-preview hello.java
true
foo

ちゃんと動いた。よかった。

説明

まず最初にT value();を持ったインターフェースを定義した。Mapping<Boolean>Boolean value()を持った型になる。

sealed interface Mapping<T> {
  T value();
}

↑のインターフェースはSealed Classesにしてみた。別に今回の例ではSealed Classにする必要はないかなーとは思ったんだけど、なんとなく使ってみたかったから使った。わいわい。

ということで↓のようにABを定義した。これでMappingの実装クラスはABだけに限定される。

sealed interface Mapping<T> {
  T value();

  record A(Boolean value) implements Mapping<Boolean> {}

  record B(String value) implements Mapping<String> {}
}

ついでに、このABRecord Classesにした。これもただ使いたかっただけ。AはBooleanの値を持ったクラス、BはStringの値を持ったクラスになっている。

getValue()は、ちょっととばしてmain関数の中。

  var values = Arrays.asList(
      new Mapping.A(true),
      new Mapping.B("foo")
  );

  var a = getValue(values, Mapping.A.class);
  println(a.orElseThrow());

Mappingのリストを作って、その中からAの値を型安全に取り出す、というのをやっている。

で、そのgetValue()がこう。

<T> Optional<T> getValue(
    List<? extends Mapping<?>> values,
    Class<? extends Mapping<T>> clazz) {
  return values.stream()
      .filter(clazz::isInstance)
      .map(clazz::cast)
      .findFirst()
      .map(Mapping::value);
}

取り出す値の型をGenericsのType Parameterにしてみた。

clazzでクラスを受け取って、そのインスタンスをvaluesの中から見つけてきてその型にキャストして最初の値を取得している。

存在しない場合を考慮してOptionalにしたけど、TSの方に合わせるならここでorElseThrow()してもよかったかもしれない。

そんな感じ

おもしろかった。TypeScriptの柔らかい感じも好きだけど、JavaはJavaで型がしっかりしてていいなーと思った。どっちも好き。

参考にしたサイト