From bd8d3a5bfde0e7371ddc545f69a90786ff5480d6 Mon Sep 17 00:00:00 2001 From: felix Date: Mon, 2 Feb 2026 11:12:52 +0100 Subject: [PATCH 1/8] Added CanEqual support --- .../src/main/scala/neotype/package.scala | 4 ++++ .../src/test/scala/neotype/NewtypeSpec.scala | 23 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/modules/core/shared/src/main/scala/neotype/package.scala b/modules/core/shared/src/main/scala/neotype/package.scala index da77274..549b736 100644 --- a/modules/core/shared/src/main/scala/neotype/package.scala +++ b/modules/core/shared/src/main/scala/neotype/package.scala @@ -143,6 +143,8 @@ abstract class Newtype[A] extends TypeWrapper[A]: object Newtype: type WithType[A, B] = Newtype[A] { type Type = B } + given canEqual[A, B](using Newtype.WithType[A, B], CanEqual[A, A]): CanEqual[B, B] = CanEqual.derived + extension [A, B](value: B)(using newtype: Newtype.WithType[A, B] ) // @@ -182,6 +184,8 @@ abstract class Subtype[A] extends TypeWrapper[A]: object Subtype: type WithType[A, B <: A] = Subtype[A] { type Type = B } + given canEqual[A, B <: A](using Subtype.WithType[A, B], CanEqual[A, A]): CanEqual[B, B] = CanEqual.derived + /** Typeclass for both Newtype and Subtype wrappers. */ trait WrappedType[Underlying, Wrapped]: type Wrapper <: TypeWrapper[Underlying] { type Type = Wrapped } diff --git a/modules/core/shared/src/test/scala/neotype/NewtypeSpec.scala b/modules/core/shared/src/test/scala/neotype/NewtypeSpec.scala index 25367a0..34855d5 100644 --- a/modules/core/shared/src/test/scala/neotype/NewtypeSpec.scala +++ b/modules/core/shared/src/test/scala/neotype/NewtypeSpec.scala @@ -285,4 +285,27 @@ object NewtypeSpec extends ZIOSpecDefault: assertTrue(error.getMessage == "Must be the secret string!") } } + + suiteAll("CanEqual") { + import scala.language.strictEquality + + test("newtypes should be able to be compared to their own types")( + assertTrue(PositiveIntNewtype(10) == PositiveIntNewtype(10)) + ) + + test("newtypes should not be able to be compared to their underlying types") { + + val res = typeCheckErrors("""PositiveIntNewtype(10) == 10""").head + + assertTrue(res.message contains "Values of types neotype.PositiveIntNewtype.Type and Int cannot be compared with == or !=.") + } + + test("subtypes should be able to be compared to their own types")( + assertTrue(PositiveIntSubtype(10) == PositiveIntSubtype(10)) + ) + + test("subtypes should be able to be compared to their underlying types") ( + assertTrue(PositiveIntSubtype(10) == 10) + ) + } } From ed532c67eeb4eb5f1d3fb714db82a14ad213d6d9 Mon Sep 17 00:00:00 2001 From: felix Date: Mon, 2 Feb 2026 11:22:26 +0100 Subject: [PATCH 2/8] Added tests where the underlying type has no CanEqual --- .../src/test/scala/neotype/NewtypeSpec.scala | 16 ++++++++++++++++ .../src/test/scala/neotype/TestNewtypes.scala | 8 ++++++++ 2 files changed, 24 insertions(+) diff --git a/modules/core/shared/src/test/scala/neotype/NewtypeSpec.scala b/modules/core/shared/src/test/scala/neotype/NewtypeSpec.scala index 34855d5..4a1881f 100644 --- a/modules/core/shared/src/test/scala/neotype/NewtypeSpec.scala +++ b/modules/core/shared/src/test/scala/neotype/NewtypeSpec.scala @@ -300,6 +300,14 @@ object NewtypeSpec extends ZIOSpecDefault: assertTrue(res.message contains "Values of types neotype.PositiveIntNewtype.Type and Int cannot be compared with == or !=.") } + test("newtypes should not be able to be if their underlying type is not comparable") { + val uncomparableNewtype = UncomparableNewtype(Uncomparable(10)) + + val res = typeCheckErrors("""uncomparableNewtype == uncomparableNewtype""").head + + assertTrue(res.message contains "Values of types neotype.UncomparableNewtype.Type and neotype.UncomparableNewtype.Type cannot be compared with == or !=.") + } + test("subtypes should be able to be compared to their own types")( assertTrue(PositiveIntSubtype(10) == PositiveIntSubtype(10)) ) @@ -307,5 +315,13 @@ object NewtypeSpec extends ZIOSpecDefault: test("subtypes should be able to be compared to their underlying types") ( assertTrue(PositiveIntSubtype(10) == 10) ) + + test("subtypes should not be able to be if their underlying type is not comparable") { + val uncomparableSubtype = UncomparableSubtype(Uncomparable(10)) + + val res = typeCheckErrors("""uncomparableSubtype == Uncomparable(10)""").head + + assertTrue(res.message contains "Values of types neotype.UncomparableSubtype.Type and neotype.Uncomparable cannot be compared with == or !=.") + } } } diff --git a/modules/core/shared/src/test/scala/neotype/TestNewtypes.scala b/modules/core/shared/src/test/scala/neotype/TestNewtypes.scala index c28a2d4..c2818bf 100644 --- a/modules/core/shared/src/test/scala/neotype/TestNewtypes.scala +++ b/modules/core/shared/src/test/scala/neotype/TestNewtypes.scala @@ -118,3 +118,11 @@ type NonNegativeCents = NonNegativeCents.Type object NonNegativeCents extends Subtype[Cents]: override inline def validate(input: Cents): Boolean = input.unwrap >= 0 + +case class Uncomparable(value: Int) + +type UncomparableNewtype = UncomparableNewtype.Type +object UncomparableNewtype extends Newtype[Uncomparable] + +type UncomparableSubtype = UncomparableSubtype.Type +object UncomparableSubtype extends Subtype[Uncomparable] From 3da7df9f06584182ac9729864799a2c14a0d2f6d Mon Sep 17 00:00:00 2001 From: felix Date: Mon, 2 Feb 2026 11:23:30 +0100 Subject: [PATCH 3/8] Formatted --- .../src/main/scala/neotype/interop/doobie/DoobieInstances.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/neotype-doobie/shared/src/main/scala/neotype/interop/doobie/DoobieInstances.scala b/modules/neotype-doobie/shared/src/main/scala/neotype/interop/doobie/DoobieInstances.scala index 9a47b12..b39f390 100644 --- a/modules/neotype-doobie/shared/src/main/scala/neotype/interop/doobie/DoobieInstances.scala +++ b/modules/neotype-doobie/shared/src/main/scala/neotype/interop/doobie/DoobieInstances.scala @@ -17,7 +17,7 @@ given put[A, B](using nt: WrappedType[A, B], put: Put[A]): Put[B] = nt.unsafeMakeF(put) given read[A, B](using wrappedType: WrappedType[A, B], read: Read[A]): Read[B] = - read.map(value => wrappedType.makeOrThrow(value)) + read.map(value => wrappedType.makeOrThrow(value)) given write[A, B](using wrappedType: WrappedType[A, B], write: Write[A]): Write[B] = write.contramap(wrappedType.unwrap) From 146de1e6446afbe6ce071e49d3f3adc6bbe9bf66 Mon Sep 17 00:00:00 2001 From: felix Date: Mon, 2 Feb 2026 11:47:05 +0100 Subject: [PATCH 4/8] Removed unused variables --- .../core/shared/src/test/scala/neotype/NewtypeSpec.scala | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/modules/core/shared/src/test/scala/neotype/NewtypeSpec.scala b/modules/core/shared/src/test/scala/neotype/NewtypeSpec.scala index 4a1881f..558282c 100644 --- a/modules/core/shared/src/test/scala/neotype/NewtypeSpec.scala +++ b/modules/core/shared/src/test/scala/neotype/NewtypeSpec.scala @@ -301,9 +301,8 @@ object NewtypeSpec extends ZIOSpecDefault: } test("newtypes should not be able to be if their underlying type is not comparable") { - val uncomparableNewtype = UncomparableNewtype(Uncomparable(10)) - val res = typeCheckErrors("""uncomparableNewtype == uncomparableNewtype""").head + val res = typeCheckErrors("""UncomparableNewtype(Uncomparable(10)) == UncomparableNewtype(Uncomparable(10))""").head assertTrue(res.message contains "Values of types neotype.UncomparableNewtype.Type and neotype.UncomparableNewtype.Type cannot be compared with == or !=.") } @@ -317,9 +316,8 @@ object NewtypeSpec extends ZIOSpecDefault: ) test("subtypes should not be able to be if their underlying type is not comparable") { - val uncomparableSubtype = UncomparableSubtype(Uncomparable(10)) - val res = typeCheckErrors("""uncomparableSubtype == Uncomparable(10)""").head + val res = typeCheckErrors("""UncomparableSubtype(Uncomparable(10)) == Uncomparable(10)""").head assertTrue(res.message contains "Values of types neotype.UncomparableSubtype.Type and neotype.Uncomparable cannot be compared with == or !=.") } From 8093289aeae87e03239bcb66386894a6fc2af8c1 Mon Sep 17 00:00:00 2001 From: felix Date: Mon, 2 Feb 2026 12:20:19 +0100 Subject: [PATCH 5/8] Renamed some classes and added tests --- .../shared/src/test/scala/neotype/NewtypeSpec.scala | 13 +++++++++---- .../src/test/scala/neotype/TestNewtypes.scala | 10 +++++----- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/modules/core/shared/src/test/scala/neotype/NewtypeSpec.scala b/modules/core/shared/src/test/scala/neotype/NewtypeSpec.scala index 558282c..05c7ad8 100644 --- a/modules/core/shared/src/test/scala/neotype/NewtypeSpec.scala +++ b/modules/core/shared/src/test/scala/neotype/NewtypeSpec.scala @@ -253,6 +253,11 @@ object NewtypeSpec extends ZIOSpecDefault: assertTrue(res.message contains "Neotype Error") } + test("comparison between types that don't have a CanEqual should be possible when strictEquality is not enabled"){ + val comparison = NoCanEqualNewtype(NoCanEqual(10)) == NoCanEqualSubtype(NoCanEqual(10)) + assertTrue(comparison) + } + suiteAll("makeOrThrow") { test("newtype success") { val res = PositiveIntNewtype.makeOrThrow(1) @@ -302,9 +307,9 @@ object NewtypeSpec extends ZIOSpecDefault: test("newtypes should not be able to be if their underlying type is not comparable") { - val res = typeCheckErrors("""UncomparableNewtype(Uncomparable(10)) == UncomparableNewtype(Uncomparable(10))""").head + val res = typeCheckErrors("""NoCanEqualNewtype(NoCanEqual(10)) == NoCanEqualNewtype(NoCanEqual(10))""").head - assertTrue(res.message contains "Values of types neotype.UncomparableNewtype.Type and neotype.UncomparableNewtype.Type cannot be compared with == or !=.") + assertTrue(res.message contains "Values of types neotype.NoCanEqualNewtype.Type and neotype.NoCanEqualNewtype.Type cannot be compared with == or !=.") } test("subtypes should be able to be compared to their own types")( @@ -317,9 +322,9 @@ object NewtypeSpec extends ZIOSpecDefault: test("subtypes should not be able to be if their underlying type is not comparable") { - val res = typeCheckErrors("""UncomparableSubtype(Uncomparable(10)) == Uncomparable(10)""").head + val res = typeCheckErrors("""NoCanEqualSubtype(NoCanEqual(10)) == NoCanEqual(10)""").head - assertTrue(res.message contains "Values of types neotype.UncomparableSubtype.Type and neotype.Uncomparable cannot be compared with == or !=.") + assertTrue(res.message contains "Values of types neotype.NoCanEqualSubtype.Type and neotype.NoCanEqual cannot be compared with == or !=.") } } } diff --git a/modules/core/shared/src/test/scala/neotype/TestNewtypes.scala b/modules/core/shared/src/test/scala/neotype/TestNewtypes.scala index c2818bf..8efd815 100644 --- a/modules/core/shared/src/test/scala/neotype/TestNewtypes.scala +++ b/modules/core/shared/src/test/scala/neotype/TestNewtypes.scala @@ -119,10 +119,10 @@ object NonNegativeCents extends Subtype[Cents]: override inline def validate(input: Cents): Boolean = input.unwrap >= 0 -case class Uncomparable(value: Int) +case class NoCanEqual(value: Int) -type UncomparableNewtype = UncomparableNewtype.Type -object UncomparableNewtype extends Newtype[Uncomparable] +type NoCanEqualNewtype = NoCanEqualNewtype.Type +object NoCanEqualNewtype extends Newtype[NoCanEqual] -type UncomparableSubtype = UncomparableSubtype.Type -object UncomparableSubtype extends Subtype[Uncomparable] +type NoCanEqualSubtype = NoCanEqualSubtype.Type +object NoCanEqualSubtype extends Subtype[NoCanEqual] From c625bf52fe4f0b5fd3f2aea7065dbfe09a549513 Mon Sep 17 00:00:00 2001 From: felix Date: Mon, 2 Feb 2026 12:24:06 +0100 Subject: [PATCH 6/8] Added Either-test --- .../core/shared/src/test/scala/neotype/NewtypeSpec.scala | 6 ++++-- .../scala/neotype/interop/doobie/DoobieInstancesSpec.scala | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/modules/core/shared/src/test/scala/neotype/NewtypeSpec.scala b/modules/core/shared/src/test/scala/neotype/NewtypeSpec.scala index 05c7ad8..5ba7a22 100644 --- a/modules/core/shared/src/test/scala/neotype/NewtypeSpec.scala +++ b/modules/core/shared/src/test/scala/neotype/NewtypeSpec.scala @@ -254,8 +254,10 @@ object NewtypeSpec extends ZIOSpecDefault: } test("comparison between types that don't have a CanEqual should be possible when strictEquality is not enabled"){ - val comparison = NoCanEqualNewtype(NoCanEqual(10)) == NoCanEqualSubtype(NoCanEqual(10)) - assertTrue(comparison) + val x: Right[Nothing, NoCanEqualNewtype] = Right(NoCanEqualNewtype(NoCanEqual(10))) + val y: Either[Throwable, NoCanEqualSubtype] = Right(NoCanEqualSubtype(NoCanEqual(10))) + + assertTrue(x == y) } suiteAll("makeOrThrow") { diff --git a/modules/neotype-doobie/shared/src/test/scala/neotype/interop/doobie/DoobieInstancesSpec.scala b/modules/neotype-doobie/shared/src/test/scala/neotype/interop/doobie/DoobieInstancesSpec.scala index 97e2a1a..5ee1593 100644 --- a/modules/neotype-doobie/shared/src/test/scala/neotype/interop/doobie/DoobieInstancesSpec.scala +++ b/modules/neotype-doobie/shared/src/test/scala/neotype/interop/doobie/DoobieInstancesSpec.scala @@ -7,7 +7,8 @@ import _root_.doobie.util.transactor.Transactor import cats.Show import cats.effect.IO import cats.effect.unsafe.implicits.global -import neotype.{Newtype, Subtype} +import neotype.Newtype +import neotype.Subtype import neotype.common.NonEmptyString import neotype.interop.doobie.given import neotype.test.definitions.* From 96b39c23396a9f1332cbad94074b4436b21bf9c7 Mon Sep 17 00:00:00 2001 From: felix Date: Tue, 3 Feb 2026 10:03:14 +0100 Subject: [PATCH 7/8] Removed Subtype CanEqual, fixed some tests! --- modules/core/shared/src/main/scala/neotype/package.scala | 2 -- .../src/test/scala/neotype/interop/caliban/CalibanSpec.scala | 2 +- .../src/test/scala/neotype/interop/chimney/ChimneySpec.scala | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/modules/core/shared/src/main/scala/neotype/package.scala b/modules/core/shared/src/main/scala/neotype/package.scala index 549b736..c40d797 100644 --- a/modules/core/shared/src/main/scala/neotype/package.scala +++ b/modules/core/shared/src/main/scala/neotype/package.scala @@ -184,8 +184,6 @@ abstract class Subtype[A] extends TypeWrapper[A]: object Subtype: type WithType[A, B <: A] = Subtype[A] { type Type = B } - given canEqual[A, B <: A](using Subtype.WithType[A, B], CanEqual[A, A]): CanEqual[B, B] = CanEqual.derived - /** Typeclass for both Newtype and Subtype wrappers. */ trait WrappedType[Underlying, Wrapped]: type Wrapper <: TypeWrapper[Underlying] { type Type = Wrapped } diff --git a/modules/neotype-caliban/shared/src/test/scala/neotype/interop/caliban/CalibanSpec.scala b/modules/neotype-caliban/shared/src/test/scala/neotype/interop/caliban/CalibanSpec.scala index c5d3b42..ae01f64 100644 --- a/modules/neotype-caliban/shared/src/test/scala/neotype/interop/caliban/CalibanSpec.scala +++ b/modules/neotype-caliban/shared/src/test/scala/neotype/interop/caliban/CalibanSpec.scala @@ -57,7 +57,7 @@ object CalibanSpec extends ZIOSpecDefault: }, test("ArgBuilder success") { val argBuilder = summonInline[ArgBuilder[SimpleSubtype]] - assertTrue(argBuilder.build(IntValue(123)) == Right(SimpleNewtype(123))) + assertTrue(argBuilder.build(IntValue(123)) == Right(SimpleSubtype(123))) } ) ) diff --git a/modules/neotype-chimney/shared/src/test/scala/neotype/interop/chimney/ChimneySpec.scala b/modules/neotype-chimney/shared/src/test/scala/neotype/interop/chimney/ChimneySpec.scala index 8787c42..d21fba3 100644 --- a/modules/neotype-chimney/shared/src/test/scala/neotype/interop/chimney/ChimneySpec.scala +++ b/modules/neotype-chimney/shared/src/test/scala/neotype/interop/chimney/ChimneySpec.scala @@ -33,7 +33,7 @@ object ChimneySpec extends ZIOSpecDefault: test("partially transform success") { val string = "hello world" val result = string.transformIntoPartial[ValidatedSubtype] - assertTrue(result.asEither == Right(ValidatedNewtype("hello world"))) + assertTrue(result.asEither == Right(ValidatedSubtype("hello world"))) }, test("partially transform failure") { val string = "hello" From 40082f1739d1c1736119b143ada262e0bf96a533 Mon Sep 17 00:00:00 2001 From: felix Date: Tue, 3 Feb 2026 11:54:07 +0100 Subject: [PATCH 8/8] Fixed test --- modules/core/shared/src/test/scala/neotype/NewtypeSpec.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/core/shared/src/test/scala/neotype/NewtypeSpec.scala b/modules/core/shared/src/test/scala/neotype/NewtypeSpec.scala index 5ba7a22..7dbe2e7 100644 --- a/modules/core/shared/src/test/scala/neotype/NewtypeSpec.scala +++ b/modules/core/shared/src/test/scala/neotype/NewtypeSpec.scala @@ -326,7 +326,7 @@ object NewtypeSpec extends ZIOSpecDefault: val res = typeCheckErrors("""NoCanEqualSubtype(NoCanEqual(10)) == NoCanEqual(10)""").head - assertTrue(res.message contains "Values of types neotype.NoCanEqualSubtype.Type and neotype.NoCanEqual cannot be compared with == or !=.") + assertTrue(res.message contains "Values of types neotype.NoCanEqualSubtype.Type and neotype.NoCanEqual cannot be compared with == or !=") } } }