From 387a1dbb1df44b4f4298daf32474d6dca9ee3887 Mon Sep 17 00:00:00 2001 From: loathwine Date: Tue, 26 May 2026 15:42:13 +0200 Subject: [PATCH] Add KeyEncoder and KeyDecoder instances for newtypes to support Map keys in circe --- .../interop/circe/CirceInstances.scala | 6 +++++ .../neotype/interop/circe/CirceJsonSpec.scala | 22 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/modules/neotype-circe/shared/src/main/scala/neotype/interop/circe/CirceInstances.scala b/modules/neotype-circe/shared/src/main/scala/neotype/interop/circe/CirceInstances.scala index c1fcb49..47a831c 100644 --- a/modules/neotype-circe/shared/src/main/scala/neotype/interop/circe/CirceInstances.scala +++ b/modules/neotype-circe/shared/src/main/scala/neotype/interop/circe/CirceInstances.scala @@ -11,3 +11,9 @@ given [A, B](using nt: WrappedType[A, B], encoder: Encoder[A]): Encoder[B] = given [A, B](using nt: WrappedType[A, B], codec: Codec[A]): Codec[B] = codec.iemap(nt.make(_))(nt.unwrap) + +given [A, B](using nt: WrappedType[A, B], keyDecoder: KeyDecoder[A]): KeyDecoder[B] = + KeyDecoder.instance(s => keyDecoder(s).flatMap(a => nt.make(a).toOption)) + +given [A, B](using nt: WrappedType[A, B], keyEncoder: KeyEncoder[A]): KeyEncoder[B] = + keyEncoder.contramap(nt.unwrap) diff --git a/modules/neotype-circe/shared/src/test/scala/neotype/interop/circe/CirceJsonSpec.scala b/modules/neotype-circe/shared/src/test/scala/neotype/interop/circe/CirceJsonSpec.scala index df00761..b798deb 100644 --- a/modules/neotype-circe/shared/src/test/scala/neotype/interop/circe/CirceJsonSpec.scala +++ b/modules/neotype-circe/shared/src/test/scala/neotype/interop/circe/CirceJsonSpec.scala @@ -5,6 +5,7 @@ import io.circe.syntax.* import neotype.interop.circe.given import neotype.test.* import neotype.test.definitions.* +import zio.test.* // Circe doesn't have a unified Codec type that's commonly used, // so we create a stub combining Decoder and Encoder @@ -62,3 +63,24 @@ object CirceJsonSpec extends JsonLibrarySpec[CirceCodec]("Circe", CirceLibrary): override protected def listHolderCodec: Option[CirceCodec[ListHolder]] = Some(summon[CirceCodec[ListHolder]]) + + override protected def additionalSuites: List[Spec[Any, Nothing]] = List( + suite("Map with newtype key")( + test("decode success") { + val json = """{"hello":1,"world":2}""" + val parsed = parser.decode[Map[ValidatedNewtype, Int]](json) + assertTrue( + parsed == Right(Map(ValidatedNewtype("hello") -> 1, ValidatedNewtype("world") -> 2)) + ) + }, + test("decode failure - empty key fails validation") { + val json = """{"":1}""" + val parsed = parser.decode[Map[ValidatedNewtype, Int]](json) + assertTrue(parsed.isLeft) + }, + test("encode") { + val json = Map(ValidatedNewtype("hello") -> 1, ValidatedNewtype("meaning") -> 42).asJson.noSpaces + assertTrue(json == """{"hello":1,"meaning":42}""") + } + ) + )