Lambdaカクテル

京都在住Webエンジニアの日記です

Invite link for Scalaわいわいランド

ScalaのCirceでEnumをUntaggedなJSONにするためのエンコーダを定義する

生きているとEnumをJSONにしたり、JSONをEnumに戻したりしたくなることがある。エンジニアの仕事の半分はJSONという煮干しを作ったりそれを戻したりすることなので、もはや呼吸をするようにエンコードしたりデコードしたりする。ええ?YAMLがお好き?すみませんね〜ウチはJSON出汁なんですよ〜

ScalaのEnumとJSON

さて、こういうJSONを作りたいとする。

{
  // ...
  "principal": [ { "user_id": "foobarkun@example.com" } ]
}

これは実物から持ってきた。ところで、

{
  // ...
  "principal": [ { "group_id": "foobargroup@example.com" } ]
}

こういうパターンもある。 もちろん、こいつらが混合することもある:

{
  // ...
  "principal": [ { "user_id": "foobarkun@example.com" }, { "group_id": "foobargroup@example.com" } ]
}

つまり、principalは、user_idフィールドを持つオブジェクトか、group_idフィールドを持つオブジェクトかの、どちらかのパターンをとることになる。

データがいくつかのパターンを取るときに何を使うかといえば、(Scala 3では)Enumだ:

enum Principal:
  case User(user_id: String)
  case Group(group_id: String)

こんな感じにPrincipalを定義して、principalフィールドにこれを充てれば終わりだ。

EnumをどうJSONにマップするのか

ところで、EnumとJSONとの関係は一筋縄にはいかない。四筋縄くらいある。というのも、JSON単体にはEnumのどの選択肢を送ってきてるかを識別するための機能がない、つまりどれを送っているのか分からないからだ。情報量を失わずにJSONとEnumとを相互変換する"自然"な方法がないのだ。シグネチャから一意に決まりそうなこともあるが、普通はそうならない。

例えばこういうEnumがあったとする:

enum Op:
  case Add(lhs: Int, rhs: Int)
  case Mul(lhs: Int, rhs: Int)

そして、以下のJSONのopフィールドがOpに対応することは分かっているとする:

{
  "op": { "lhs": 42, "rhs": 666 }
}

しかしこのJSONからは、これがAddなのかMulなのかを復元する情報を取り出せない。

このため、一般にenumとJSONとを対応付ける、つまり上手いことできた写像を考えるためには、いくつかのパターンを利用することになる。少し説明する。

externally tagged

写像としてexternally taggedを採用する場合、オブジェクトが外側に挟まる:

{
  "op": {
    "Add": { "lhs": 42, "rhs": 666 }
  }
}

「Opのどれなのか」という情報が保存されているため、このパターンはEnumからJSONとJSONからEnumとの相互変換が可能になる。

internally tagged

上有政策、下有対策と言うように、externalがあればinternalもある。写像としてinternally taggedを使うと、オブジェクトを挟むかわりにフィールドを増やすことで対処する:

{
  "op": { "_type": "Add", "lhs": 42, "rhs": 666 }
}

これはTypeScriptのDiscriminated Union Typesと同じ発想だ。ただし、discriminatorにどういうフィールド名を採用するかは「慣例次第」になってしまう。

こちらもexternally tagged同様、相互変換が可能である。

adjacently tagged

adjacentlyというのは隣接して、という意味。internally taggedがdiscriminatorに専用のフィールドを割当てたように、これをより推し進めて値の部分も専用のフィールドに押し込めた形。

{
  "op": { "t": "Add", "c": { "lhs": 42, "rhs": 666 } }
}

internally taggedとexternally taggedが合体したような印象がある。serdeのドキュメントによれば、Haskell界隈でよく見るらしい。

untagged

タグ付けをしないバンカラのための写像である。当然だがタグがないので、一対一対応になるとは限らない(細心の注意を払ってDecoderを組み立てることになる)。最初に挙げた例がこれ。

{
  "op": { "lhs": 42, "rhs": 666 }
}

Scalaではどうするのか

詳しくは以下の記事を読んでほしいが、adjacently tagged以外はCirceが対応している:

zenn.dev

冒頭の話題に戻ると、今回欲しいJSONはuntaggedであって、JSONからEnumを復元することについては考えなくていいので、Encoderについてのみ考えることにする。

前掲の記事では型アノテーションを使ってエンコードしているが、case class中の1フィールドだとそうはいかないので、別のやりかたでEncoderを組み立ててみた:

enum Principal:
  case User(user_id: String)
  case Group(group_id: String)

object Principal:
  given Encoder[User] = deriveEncoder
  given Encoder[Group] = deriveEncoder

  given Encoder[Principal] = Encoder.instance:
    case u: User  => summon[Encoder[User]].apply(u)
    case g: Group => summon[Encoder[Group]].apply(g)

ただこのやりかただとenumがデカくなるにつれてボイラープレートを増やさなければならないので$O(n)$という感じであり、SDGsに反している。地球環境を守るためにはもうちょっと賢い手立てを考えることができそうだが・・・

参考文献

zenn.dev

igaguri.hatenablog.com

serde.rs

★記事をRTしてもらえると喜びます
Webアプリケーション開発関連の記事を投稿しています.読者になってみませんか?