From 4b6376128ca1233dea1a50a11fc074425477c3a4 Mon Sep 17 00:00:00 2001 From: Oscar Boykin Date: Mon, 16 Feb 2026 08:28:30 -1000 Subject: [PATCH 1/6] Add Nullable type to cats data with instances --- .../src/main/scala-3/cats/data/Nullable.scala | 484 ++++++++++++++++++ .../scala-3/cats/tests/NullableSuite.scala | 275 ++++++++++ 2 files changed, 759 insertions(+) create mode 100644 core/src/main/scala-3/cats/data/Nullable.scala create mode 100644 tests/shared/src/test/scala-3/cats/tests/NullableSuite.scala diff --git a/core/src/main/scala-3/cats/data/Nullable.scala b/core/src/main/scala-3/cats/data/Nullable.scala new file mode 100644 index 0000000000..84a1007778 --- /dev/null +++ b/core/src/main/scala-3/cats/data/Nullable.scala @@ -0,0 +1,484 @@ +/* + * Copyright (c) 2026 Typelevel + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package cats.data + +import cats.{Applicative, Eq, Eval, Hash, Monad, Monoid, Order, PartialOrder, Semigroup, Show, Traverse} +import scala.language.strictEquality +import scala.quoted.* + +opaque type Nullable[+A] = A | Null + +object Nullable extends NullableInstances { + // If we enable explicit nulls, strict equality needs this `CanEqual` for `== null` checks. + // We keep it `private[data]` (not `private`) because explicit nulls are not enabled right now, + // and a `private given` would otherwise trigger an unused-private-member warning. + private[data] given [A]: CanEqual[A | Null, Null] = CanEqual.derived + + extension [A](inline nullable: Nullable[A]) { + inline def fold[B](inline ifNull: => B)(inline f: A => B): B = + ${ foldImpl('nullable, 'ifNull, 'f) } + + inline def isNull: Boolean = { + val value: A | Null = nullable + value == null + } + + inline def nonNull: Boolean = + !isNull + + inline def map[B](inline f: A => B): Nullable[B] = + fold(null: Null)(f) + + inline def orNull: A | Null = + nullable + + inline def toOption: Option[A] = + fold(None)(Some(_)) + + inline def iterator: Iterator[A] = + fold(Iterator.empty)(Iterator.single(_)) + } + + inline def apply[A](inline a: A | Null): Nullable[A] = + a + + def fromOption[A](opt: Option[A]): Nullable[A] = + opt match { + case Some(a) => a + case None => null + } + + inline def empty[A]: Nullable[A] = + null + + private[data] def toOptionNullable[A](nullable: Nullable[A]): Option[Nullable[A]] = { + val value: A | Null = nullable + if (value == null) None else Some(nullable) + } + + private[data] def eqvNullable[A](na: Nullable[A], nb: Nullable[A])(using A: Eq[A]): Boolean = { + val a: A | Null = na + val b: A | Null = nb + if (a == null) b == null + else if (b == null) false + else A.eqv(a.asInstanceOf[A], b.asInstanceOf[A]) + } + + private[data] def hashNullable[A](nullable: Nullable[A])(using A: Hash[A]): Int = { + val value: A | Null = nullable + if (value == null) None.hashCode() + else A.hash(value.asInstanceOf[A]) + } + + private[data] def partialCompareNullable[A](na: Nullable[A], nb: Nullable[A])(using A: PartialOrder[A]): Double = { + val a: A | Null = na + val b: A | Null = nb + if (a == null) { + if (b == null) 0.0 else -1.0 + } else if (b == null) { + 1.0 + } else { + A.partialCompare(a.asInstanceOf[A], b.asInstanceOf[A]) + } + } + + private[data] def compareNullable[A](na: Nullable[A], nb: Nullable[A])(using A: Order[A]): Int = { + val a: A | Null = na + val b: A | Null = nb + if (a == null) { + if (b == null) 0 else -1 + } else if (b == null) { + 1 + } else { + A.compare(a.asInstanceOf[A], b.asInstanceOf[A]) + } + } + + private def combineNullable[A](nx: Nullable[A], ny: Nullable[A])(using A: Semigroup[A]): Nullable[A] = { + val x: A | Null = nx + val y: A | Null = ny + if (x == null) y + else if (y == null) x + else A.combine(x.asInstanceOf[A], y.asInstanceOf[A]) + } + + given [A](using A: Semigroup[A]): Monoid[Nullable[A]] with { + def empty: Nullable[A] = + Nullable.empty + + def combine(nx: Nullable[A], ny: Nullable[A]): Nullable[A] = + combineNullable(nx, ny) + } + + given [A](using A: Show[A]): Show[Nullable[A]] with { + def show(nullable: Nullable[A]): String = { + val value: A | Null = nullable + if (value == null) java.lang.String.valueOf(value.asInstanceOf[AnyRef]).asInstanceOf[String] + else A.show(value.asInstanceOf[A]) + } + } + + /* + * These methods intentionally use direct null checks instead of `fa.fold(...)`. + * `fold` is implemented as an inline macro in this same source file, and Scala 3 + * rejects calls from non-inline methods to a macro defined in the same file. + * (`Traverse` methods cannot be inline overrides here.) + */ + given catsDataTraverseForNullable: Traverse[Nullable] with { + private[this] val nullableUnit: Nullable[Unit] = () + private[this] val nullPair: (Null, Null) = (null, null) + + override def map[A, B](fa: Nullable[A])(f: A => B): Nullable[B] = + val value: A | Null = fa + if (value == null) Nullable.empty else f(value.asInstanceOf[A]) + + override def as[A, B](fa: Nullable[A], b: B): Nullable[B] = + val value: A | Null = fa + if (value == null) Nullable.empty else b + + override def void[A](fa: Nullable[A]): Nullable[Unit] = + val value: A | Null = fa + if (value == null) Nullable.empty else nullableUnit + + override def tupleLeft[A, B](fa: Nullable[A], b: B): Nullable[(B, A)] = + val value: A | Null = fa + if (value == null) Nullable.empty else (b, value.asInstanceOf[A]) + + override def tupleRight[A, B](fa: Nullable[A], b: B): Nullable[(A, B)] = + val value: A | Null = fa + if (value == null) Nullable.empty else (value.asInstanceOf[A], b) + + override def fproduct[A, B](fa: Nullable[A])(f: A => B): Nullable[(A, B)] = + val value: A | Null = fa + if (value == null) Nullable.empty + else { + val a = value.asInstanceOf[A] + (a, f(a)) + } + + override def fproductLeft[A, B](fa: Nullable[A])(f: A => B): Nullable[(B, A)] = + val value: A | Null = fa + if (value == null) Nullable.empty + else { + val a = value.asInstanceOf[A] + (f(a), a) + } + + override def ifF[A](fb: Nullable[Boolean])(ifTrue: => A, ifFalse: => A): Nullable[A] = + val value: Boolean | Null = fb + if (value == null) Nullable.empty + else if (value.asInstanceOf[Boolean]) ifTrue + else ifFalse + + override def unzip[A, B](fab: Nullable[(A, B)]): (Nullable[A], Nullable[B]) = + val value: (A, B) | Null = fab + if (value == null) nullPair + else { + val pair = value.asInstanceOf[(A, B)] + pair + } + + def foldLeft[A, B](fa: Nullable[A], b: B)(f: (B, A) => B): B = + val value: A | Null = fa + if (value == null) b else f(b, value.asInstanceOf[A]) + + def foldRight[A, B](fa: Nullable[A], lb: Eval[B])(f: (A, Eval[B]) => Eval[B]): Eval[B] = + val value: A | Null = fa + if (value == null) lb else f(value.asInstanceOf[A], lb) + + override def fold[A](fa: Nullable[A])(using A: Monoid[A]): A = + val value: A | Null = fa + if (value == null) A.empty else value.asInstanceOf[A] + + override def combineAllOption[A](fa: Nullable[A])(using Semigroup[A]): Option[A] = + val value: A | Null = fa + if (value == null) None else Some(value.asInstanceOf[A]) + + override def toIterable[A](fa: Nullable[A]): Iterable[A] = + val value: A | Null = fa + if (value == null) Iterable.empty else Iterable.single(value.asInstanceOf[A]) + + override def foldMap[A, B](fa: Nullable[A])(f: A => B)(using B: Monoid[B]): B = + val value: A | Null = fa + if (value == null) B.empty else f(value.asInstanceOf[A]) + + override def reduceLeftToOption[A, B](fa: Nullable[A])(f: A => B)(g: (B, A) => B): Option[B] = + val value: A | Null = fa + if (value == null) None else Some(f(value.asInstanceOf[A])) + + override def reduceRightToOption[A, B](fa: Nullable[A])(f: A => B)(g: (A, Eval[B]) => Eval[B]): Eval[Option[B]] = { + val value: A | Null = fa + if (value == null) Eval.now(None) else Eval.now(Some(f(value.asInstanceOf[A]))) + } + + override def reduceLeftOption[A](fa: Nullable[A])(f: (A, A) => A): Option[A] = + val value: A | Null = fa + if (value == null) None else Some(value.asInstanceOf[A]) + + override def reduceRightOption[A](fa: Nullable[A])(f: (A, Eval[A]) => Eval[A]): Eval[Option[A]] = { + val value: A | Null = fa + if (value == null) Eval.now(None) else Eval.now(Some(value.asInstanceOf[A])) + } + + override def minimumOption[A](fa: Nullable[A])(using Order[A]): Option[A] = + val value: A | Null = fa + if (value == null) None else Some(value.asInstanceOf[A]) + + override def maximumOption[A](fa: Nullable[A])(using Order[A]): Option[A] = + val value: A | Null = fa + if (value == null) None else Some(value.asInstanceOf[A]) + + override def get[A](fa: Nullable[A])(idx: Long): Option[A] = + if (idx != 0L) None + else { + val value: A | Null = fa + if (value == null) None else Some(value.asInstanceOf[A]) + } + + override def contains_[A](fa: Nullable[A], v: A)(using A: Eq[A]): Boolean = { + val value: A | Null = fa + if (value == null) false else A.eqv(value.asInstanceOf[A], v) + } + + override def size[A](fa: Nullable[A]): Long = + val value: A | Null = fa + if (value == null) 0L else 1L + + override def find[A](fa: Nullable[A])(f: A => Boolean): Option[A] = + val value: A | Null = fa + if (value == null) None + else { + val a = value.asInstanceOf[A] + if (f(a)) Some(a) else None + } + + override def exists[A](fa: Nullable[A])(p: A => Boolean): Boolean = + val value: A | Null = fa + if (value == null) false else p(value.asInstanceOf[A]) + + override def forall[A](fa: Nullable[A])(p: A => Boolean): Boolean = + val value: A | Null = fa + if (value == null) true else p(value.asInstanceOf[A]) + + override def toList[A](fa: Nullable[A]): List[A] = + val value: A | Null = fa + if (value == null) Nil else value.asInstanceOf[A] :: Nil + + override def filter_[A](fa: Nullable[A])(p: A => Boolean): List[A] = + val value: A | Null = fa + if (value == null) Nil + else { + val a = value.asInstanceOf[A] + if (p(a)) a :: Nil else Nil + } + + override def takeWhile_[A](fa: Nullable[A])(p: A => Boolean): List[A] = + val value: A | Null = fa + if (value == null) Nil + else { + val a = value.asInstanceOf[A] + if (p(a)) a :: Nil else Nil + } + + override def dropWhile_[A](fa: Nullable[A])(p: A => Boolean): List[A] = + val value: A | Null = fa + if (value == null) Nil + else { + val a = value.asInstanceOf[A] + if (p(a)) Nil else a :: Nil + } + + override def isEmpty[A](fa: Nullable[A]): Boolean = + val value: A | Null = fa + value == null + + override def nonEmpty[A](fa: Nullable[A]): Boolean = + !isEmpty(fa) + + override def collectFirst[A, B](fa: Nullable[A])(pf: PartialFunction[A, B]): Option[B] = + val value: A | Null = fa + if (value == null) None + else { + val a = value.asInstanceOf[A] + if (pf.isDefinedAt(a)) Some(pf(a)) else None + } + + override def collectFirstSome[A, B](fa: Nullable[A])(f: A => Option[B]): Option[B] = + val value: A | Null = fa + if (value == null) None else f(value.asInstanceOf[A]) + + override def traverseVoid[G[_], A, B](fa: Nullable[A])(f: A => G[B])(using G: Applicative[G]): G[Unit] = + val value: A | Null = fa + if (value == null) G.unit else G.void(f(value.asInstanceOf[A])) + + def traverse[G[_], A, B](fa: Nullable[A])(f: A => G[B])(using G: Applicative[G]): G[Nullable[B]] = + val value: A | Null = fa + if (value == null) G.pure(Nullable.empty[B]) + else { + val gb: G[B] = f(value.asInstanceOf[A]) + // We can treat `Nullable[B]` as `B | Null`; `widen` is typically a no-op + // for lawful/correctly implemented Functors. + val gNullable: G[Nullable[B]] = G.widen[B, B | Null](gb) + gNullable + } + + override def traverseTap[G[_], A, B](fa: Nullable[A])(f: A => G[B])(using G: Applicative[G]): G[Nullable[A]] = + val value: A | Null = fa + if (value == null) G.pure(Nullable.empty[A]) + else { + val a = value.asInstanceOf[A] + G.as(f(a), a) + } + + override def sequence[G[_], A](fga: Nullable[G[A]])(using G: Applicative[G]): G[Nullable[A]] = + val value: G[A] | Null = fga + if (value == null) G.pure(Nullable.empty[A]) + else G.widen[A, A | Null](value.asInstanceOf[G[A]]) + + override def mapAccumulate[S, A, B](init: S, fa: Nullable[A])(f: (S, A) => (S, B)): (S, Nullable[B]) = + val value: A | Null = fa + if (value == null) (init, Nullable.empty[B]) + else { + val (next, b) = f(init, value.asInstanceOf[A]) + (next, b) + } + + override def mapWithIndex[A, B](fa: Nullable[A])(f: (A, Int) => B): Nullable[B] = + val value: A | Null = fa + if (value == null) Nullable.empty else f(value.asInstanceOf[A], 0) + + override def traverseWithIndexM[G[_], A, B](fa: Nullable[A])(f: (A, Int) => G[B])(using G: Monad[G]): G[Nullable[B]] = + val value: A | Null = fa + if (value == null) G.pure(Nullable.empty[B]) + else G.widen[B, B | Null](f(value.asInstanceOf[A], 0)) + + override def zipWithIndex[A](fa: Nullable[A]): Nullable[(A, Int)] = + val value: A | Null = fa + if (value == null) Nullable.empty else (value.asInstanceOf[A], 0) + + override def traverseWithLongIndexM[G[_], A, B]( + fa: Nullable[A] + )(f: (A, Long) => G[B])(using G: Monad[G]): G[Nullable[B]] = { + val value: A | Null = fa + if (value == null) G.pure(Nullable.empty[B]) + else G.widen[B, B | Null](f(value.asInstanceOf[A], 0L)) + } + + override def mapWithLongIndex[A, B](fa: Nullable[A])(f: (A, Long) => B): Nullable[B] = + val value: A | Null = fa + if (value == null) Nullable.empty else f(value.asInstanceOf[A], 0L) + + override def zipWithLongIndex[A](fa: Nullable[A]): Nullable[(A, Long)] = + val value: A | Null = fa + if (value == null) Nullable.empty else (value.asInstanceOf[A], 0L) + + override def updated_[A, B >: A](fa: Nullable[A], idx: Long, b: B): Option[Nullable[B]] = + if (idx < 0L) None + else { + val value: A | Null = fa + if (value == null) None + else if (idx == 0L) Some(b: Nullable[B]) + else None + } + } + + /* + * Keep `fold` as a macro: + * 1. Strict equality + explicit nulls: we emit a local `CanEqual[T | Null, Null]`, + * so the generated null check remains valid under those modes. + * 2. Performance: `Expr.betaReduce` keeps `fold` codegen close to a plain `if/else` + * by reducing the `f(safe)` application at compile time when possible. + * 3. Compiler stability: the `asInstanceOf[T]` cast in the `else` branch is intentional. + * Moving/removing that cast can crash some Scala 3 versions on intersection types: + * https://github.com/scala/scala3/issues/25208 + */ + private def foldImpl[T: Type, A: Type]( + nullable: Expr[Nullable[T]], + ifNull: Expr[A], + fn: Expr[T => A] + )(using Quotes): Expr[A] = { + val nullableUnion: Expr[T | Null] = '{ $nullable.asInstanceOf[T | Null] } + + '{ + val n = $nullableUnion + given CanEqual[T | Null, Null] = CanEqual.derived + if (n == null) $ifNull + else { + val safe: T = n.asInstanceOf[T] + ${ Expr.betaReduce('{ $fn(safe) }) } + } + } + } +} + +sealed abstract private[data] class NullableInstances extends NullableInstances0 { + given [A](using A: Order[A]): Order[Nullable[A]] with { + def compare(na: Nullable[A], nb: Nullable[A]): Int = + Nullable.compareNullable(na, nb) + + override def pmin(na: Nullable[A], nb: Nullable[A]): Option[Nullable[A]] = + if (compare(na, nb) <= 0) Nullable.toOptionNullable(na) else Nullable.toOptionNullable(nb) + + override def pmax(na: Nullable[A], nb: Nullable[A]): Option[Nullable[A]] = + if (compare(na, nb) >= 0) Nullable.toOptionNullable(na) else Nullable.toOptionNullable(nb) + } +} + +private[data] trait NullableInstances0 extends NullableInstances1 { + given [A](using A: PartialOrder[A]): PartialOrder[Nullable[A]] with { + def partialCompare(na: Nullable[A], nb: Nullable[A]): Double = + Nullable.partialCompareNullable(na, nb) + + override def pmin(na: Nullable[A], nb: Nullable[A]): Option[Nullable[A]] = { + val c = partialCompare(na, nb) + // Avoid boxing by calling the primitive static function. + if (java.lang.Double.isNaN(c)) None + else if (c <= 0.0) Nullable.toOptionNullable(na) + else Nullable.toOptionNullable(nb) + } + + override def pmax(na: Nullable[A], nb: Nullable[A]): Option[Nullable[A]] = { + val c = partialCompare(na, nb) + // Avoid boxing by calling the primitive static function. + if (java.lang.Double.isNaN(c)) None + else if (c >= 0.0) Nullable.toOptionNullable(na) + else Nullable.toOptionNullable(nb) + } + } +} + +private[data] trait NullableInstances1 extends NullableInstances2 { + given [A](using A: Hash[A]): Hash[Nullable[A]] with { + def eqv(na: Nullable[A], nb: Nullable[A]): Boolean = + Nullable.eqvNullable(na, nb) + + def hash(nullable: Nullable[A]): Int = + Nullable.hashNullable(nullable) + } +} + +private[data] trait NullableInstances2 { + given [A](using A: Eq[A]): Eq[Nullable[A]] with { + def eqv(na: Nullable[A], nb: Nullable[A]): Boolean = + Nullable.eqvNullable(na, nb) + } +} diff --git a/tests/shared/src/test/scala-3/cats/tests/NullableSuite.scala b/tests/shared/src/test/scala-3/cats/tests/NullableSuite.scala new file mode 100644 index 0000000000..8343c20444 --- /dev/null +++ b/tests/shared/src/test/scala-3/cats/tests/NullableSuite.scala @@ -0,0 +1,275 @@ +/* + * Copyright (c) 2026 Typelevel + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package cats.tests + +import cats.{Applicative, Eq, Eval, Hash, Monad, Monoid, Order, PartialOrder, Semigroup, Show, Traverse} +import cats.data.Nullable +import cats.data.Nullable.* +import cats.kernel.laws.discipline.{EqTests, HashTests, MonoidTests, OrderTests, PartialOrderTests, SemigroupTests} +import cats.laws.discipline.{SerializableTests, TraverseTests} +import cats.syntax.eq.* +import org.scalacheck.{Arbitrary, Cogen, Gen} +import org.scalacheck.Prop.* + +class NullableSuite extends CatsSuite { + + private val optimizedTraverseForNullable: Traverse[Nullable] = Traverse[Nullable] + + private val defaultOnlyTraverse: Traverse[Nullable] = new Traverse[Nullable] { + override def map[A, B](fa: Nullable[A])(f: A => B): Nullable[B] = + fa.map(f) + + def foldLeft[A, B](fa: Nullable[A], b: B)(f: (B, A) => B): B = + fa.fold(b)(a => f(b, a)) + + def foldRight[A, B](fa: Nullable[A], lb: Eval[B])(f: (A, Eval[B]) => Eval[B]): Eval[B] = + fa.fold(lb)(a => f(a, lb)) + + def traverse[G[_], A, B](fa: Nullable[A])(f: A => G[B])(using G: Applicative[G]): G[Nullable[B]] = + fa.fold(G.pure(Nullable.empty[B]))(a => G.map(f(a))(Nullable(_))) + } + + given [A](using A: Arbitrary[A]): Arbitrary[Nullable[A]] = + Arbitrary(Gen.oneOf(Gen.const(Nullable.empty[A]), A.arbitrary.map(Nullable(_)))) + + given [A](using A: Cogen[A]): Cogen[Nullable[A]] = + Cogen[Option[A]].contramap(_.toOption) + + checkAll("Nullable[Int]", EqTests[Nullable[Int]].eqv) + checkAll("Eq[Nullable[Int]]", SerializableTests.serializable(Eq[Nullable[Int]])) + + checkAll("Nullable[Int]", HashTests[Nullable[Int]].hash) + checkAll("Hash[Nullable[Int]]", SerializableTests.serializable(Hash[Nullable[Int]])) + + checkAll("Nullable[Int]", PartialOrderTests[Nullable[Int]].partialOrder) + checkAll("PartialOrder[Nullable[Int]]", SerializableTests.serializable(PartialOrder[Nullable[Int]])) + + checkAll("Nullable[Int]", OrderTests[Nullable[Int]].order) + checkAll("Order[Nullable[Int]]", SerializableTests.serializable(Order[Nullable[Int]])) + + checkAll("Nullable[Int]", SemigroupTests[Nullable[Int]].semigroup) + checkAll("Semigroup[Nullable[Int]]", SerializableTests.serializable(Semigroup[Nullable[Int]])) + + checkAll("Nullable[Int]", MonoidTests[Nullable[Int]].monoid) + checkAll("Monoid[Nullable[Int]]", SerializableTests.serializable(Monoid[Nullable[Int]])) + + checkAll("Show[Nullable[Int]]", SerializableTests.serializable(Show[Nullable[Int]])) + + checkAll("Nullable[Int] with Option", TraverseTests[Nullable].traverse[Int, Int, Int, Int, Option, Option]) + checkAll("Traverse[Nullable]", SerializableTests.serializable(Traverse[Nullable])) + + test("fold") { + val nonEmpty: Nullable[Int] = Nullable(1) + val empty: Nullable[Int] = Nullable.empty + + assert(nonEmpty.fold(0)(_ + 1) === 2) + assert(empty.fold(0)(_ + 1) === 0) + } + + test("fold homomorphism with Option") { + forAll { (n: Nullable[Int], x: Int, fn: Int => Int) => + assert(n.fold(x)(fn) === n.toOption.fold(x)(fn)) + } + } + + test("fold infers union result type") { + val nonEmpty: Nullable[String] = Nullable("long") + val empty: Nullable[String] = Nullable.empty + + val nonEmptyIntOrLong: Int | Long = nonEmpty.fold(0)(_ => 1L) + val emptyIntOrLong: Int | Long = empty.fold(0)(_ => 1L) + + assert(nonEmptyIntOrLong == 1L) + assert(emptyIntOrLong == 0) + } + + test("fromOption / toOption round trip") { + forAll { (opt: Option[Int]) => + assert(Nullable.fromOption(opt).toOption === opt) + } + } + + test("iterator is consistent with Option iterator") { + forAll { (nullable: Nullable[Int]) => + assert(nullable.iterator.toList === nullable.toOption.iterator.toList) + } + } + + test("option-like Applicative is not lawful for nested Nullable") { + val candidate = new Applicative[Nullable] { + def pure[A](x: A): Nullable[A] = Nullable(x) + + override def map[A, B](fa: Nullable[A])(f: A => B): Nullable[B] = + fa.map(f) + + def ap[A, B](ff: Nullable[A => B])(fa: Nullable[A]): Nullable[B] = + ff.fold(Nullable.empty[B])(f => fa.map(f)) + } + + val a: Nullable[Int] = Nullable.empty + val f: Nullable[Int] => Int = _ => 1 + + val lhs = candidate.ap(candidate.pure(f))(candidate.pure(a)) + val rhs = candidate.pure(f(a)) + + assert(lhs =!= rhs) + } + + test("option-like Monad is not lawful for nested Nullable") { + val candidate = new Monad[Nullable] { + def pure[A](x: A): Nullable[A] = Nullable(x) + + override def map[A, B](fa: Nullable[A])(f: A => B): Nullable[B] = + fa.map(f) + + def flatMap[A, B](fa: Nullable[A])(f: A => Nullable[B]): Nullable[B] = + fa.fold(Nullable.empty[B])(f) + + def tailRecM[A, B](a: A)(f: A => Nullable[Either[A, B]]): Nullable[B] = { + def loop(current: A): Nullable[B] = + f(current).fold(Nullable.empty[B]) { + case Left(next) => loop(next) + case Right(b) => Nullable(b) + } + + loop(a) + } + } + + val a: Nullable[Int] = Nullable.empty + val f: Nullable[Int] => Nullable[Int] = _ => Nullable(1) + + val lhs = candidate.flatMap(candidate.pure(a))(f) + val rhs = f(a) + + assert(lhs =!= rhs) + } + + test("current Monoid instance cannot satisfy Group inverse with Int") { + val x: Nullable[Int] = Nullable(1) + val inverseX: Nullable[Int] = Nullable(-1) + + assert(Monoid[Nullable[Int]].combine(x, inverseX) =!= Nullable.empty) + } + + test("Show[Nullable] uses JVM null rendering for null and Show[A] otherwise") { + val empty: Nullable[Int] = Nullable.empty + val nonEmpty: Nullable[Int] = Nullable(1) + + assert(Show[Nullable[Int]].show(empty) === java.lang.String.valueOf(null.asInstanceOf[AnyRef])) + assert(Show[Nullable[Int]].show(nonEmpty) === Show[Int].show(1)) + } + + test("Functor overrides match default implementations") { + val optimized = optimizedTraverseForNullable + val defaults = defaultOnlyTraverse + + forAll { (fa: Nullable[Int], b: Int, f: Int => Long, fb: Nullable[Boolean], ifTrue: Int, ifFalse: Int) => + assert(optimized.map(fa)(f) === defaults.map(fa)(f)) + assert(optimized.as(fa, b) === defaults.as(fa, b)) + assert(optimized.void(fa) === defaults.void(fa)) + assert(optimized.tupleLeft(fa, b) === defaults.tupleLeft(fa, b)) + assert(optimized.tupleRight(fa, b) === defaults.tupleRight(fa, b)) + assert(optimized.fproduct(fa)(f) === defaults.fproduct(fa)(f)) + assert(optimized.fproductLeft(fa)(f) === defaults.fproductLeft(fa)(f)) + assert(optimized.ifF(fb)(ifTrue, ifFalse) === defaults.ifF(fb)(ifTrue, ifFalse)) + } + + forAll { (fab: Nullable[(Int, String)]) => + assert(optimized.unzip(fab) == defaults.unzip(fab)) + } + } + + test("Foldable overrides match default implementations") { + val optimized = optimizedTraverseForNullable + val defaults = defaultOnlyTraverse + val toLong: Int => Long = _.toLong + 1L + val sumLong: (Long, Int) => Long = (acc, i) => acc + i.toLong + val pfEven: PartialFunction[Int, Int] = { case i if i % 2 == 0 => i + 10 } + val firstSome: Int => Option[Int] = i => if (i % 2 == 0) Some(i + 1) else None + val traverseVoidF: Int => Option[Int] = i => if (i % 2 == 0) Some(i) else None + + forAll { (fa: Nullable[Int], idx: Long, target: Int, pred: Int => Boolean) => + assert(optimized.foldLeft(fa, 1)(_ + _) === defaults.foldLeft(fa, 1)(_ + _)) + assert(optimized.foldRight(fa, Eval.now(1))((a, eb) => eb.map(_ + a)).value === + defaults.foldRight(fa, Eval.now(1))((a, eb) => eb.map(_ + a)).value) + assert(optimized.fold(fa) === defaults.fold(fa)) + assert(optimized.combineAllOption(fa) === defaults.combineAllOption(fa)) + assert(optimized.toIterable(fa).toList === defaults.toIterable(fa).toList) + assert(optimized.foldMap(fa)(toLong) === defaults.foldMap(fa)(toLong)) + assert(optimized.reduceLeftToOption(fa)(toLong)(sumLong) === defaults.reduceLeftToOption(fa)(toLong)(sumLong)) + assert( + optimized.reduceRightToOption(fa)(toLong)((i, e) => e.map(_ + i.toLong)).value === + defaults.reduceRightToOption(fa)(toLong)((i, e) => e.map(_ + i.toLong)).value + ) + assert(optimized.reduceLeftOption(fa)(_ + _) === defaults.reduceLeftOption(fa)(_ + _)) + assert( + optimized.reduceRightOption(fa)((i, e) => e.map(_ + i)).value === + defaults.reduceRightOption(fa)((i, e) => e.map(_ + i)).value + ) + assert(optimized.minimumOption(fa) === defaults.minimumOption(fa)) + assert(optimized.maximumOption(fa) === defaults.maximumOption(fa)) + assert(optimized.get(fa)(idx) === defaults.get(fa)(idx)) + assert(optimized.contains_(fa, target) === defaults.contains_(fa, target)) + assert(optimized.size(fa) === defaults.size(fa)) + assert(optimized.find(fa)(pred) === defaults.find(fa)(pred)) + assert(optimized.exists(fa)(pred) === defaults.exists(fa)(pred)) + assert(optimized.forall(fa)(pred) === defaults.forall(fa)(pred)) + assert(optimized.toList(fa) === defaults.toList(fa)) + assert(optimized.filter_(fa)(pred) === defaults.filter_(fa)(pred)) + assert(optimized.takeWhile_(fa)(pred) === defaults.takeWhile_(fa)(pred)) + assert(optimized.dropWhile_(fa)(pred) === defaults.dropWhile_(fa)(pred)) + assert(optimized.isEmpty(fa) === defaults.isEmpty(fa)) + assert(optimized.nonEmpty(fa) === defaults.nonEmpty(fa)) + assert(optimized.collectFirst(fa)(pfEven) === defaults.collectFirst(fa)(pfEven)) + assert(optimized.collectFirstSome(fa)(firstSome) === defaults.collectFirstSome(fa)(firstSome)) + assert(optimized.traverseVoid(fa)(traverseVoidF) === defaults.traverseVoid(fa)(traverseVoidF)) + } + } + + test("Traverse overrides match default implementations") { + val optimized = optimizedTraverseForNullable + val defaults = defaultOnlyTraverse + + forAll { (fa: Nullable[Int], fga: Nullable[Option[Int]], init: Long, replacement: Int, idx: Long) => + val tapF: Int => Option[String] = i => if (i % 2 == 0) Some(i.toString) else None + val accumulateF: (Long, Int) => (Long, String) = (s, i) => (s + i.toLong, s"$s:$i") + val indexTraverseF: (Int, Int) => Option[String] = (i, n) => if ((i + n) % 2 == 0) Some(s"$i-$n") else None + val longIndexTraverseF: (Int, Long) => Option[String] = (i, n) => + if ((i.toLong + n) % 2L == 0L) Some(s"$i-$n") else None + val indexMapF: (Int, Int) => String = (i, n) => s"$i@$n" + val longIndexMapF: (Int, Long) => String = (i, n) => s"$i@$n" + + assert(optimized.traverse(fa)(tapF) === defaults.traverse(fa)(tapF)) + assert(optimized.traverseTap(fa)(tapF) === defaults.traverseTap(fa)(tapF)) + assert(optimized.sequence(fga) === defaults.sequence(fga)) + assert(optimized.mapAccumulate(init, fa)(accumulateF) == defaults.mapAccumulate(init, fa)(accumulateF)) + assert(optimized.mapWithIndex(fa)(indexMapF) === defaults.mapWithIndex(fa)(indexMapF)) + assert(optimized.traverseWithIndexM(fa)(indexTraverseF) === defaults.traverseWithIndexM(fa)(indexTraverseF)) + assert(optimized.zipWithIndex(fa) === defaults.zipWithIndex(fa)) + assert(optimized.traverseWithLongIndexM(fa)(longIndexTraverseF) === defaults.traverseWithLongIndexM(fa)(longIndexTraverseF)) + assert(optimized.mapWithLongIndex(fa)(longIndexMapF) === defaults.mapWithLongIndex(fa)(longIndexMapF)) + assert(optimized.zipWithLongIndex(fa) === defaults.zipWithLongIndex(fa)) + assert(optimized.updated_(fa, idx, replacement) === defaults.updated_(fa, idx, replacement)) + } + } +} From ffe0bf64dd9d1d3f9fac8df06751f6fb2724dd05 Mon Sep 17 00:00:00 2001 From: Oscar Boykin Date: Mon, 16 Feb 2026 08:50:34 -1000 Subject: [PATCH 2/6] fix prePR --- .../src/main/scala-3/cats/data/Nullable.scala | 594 ++++++++++++------ .../scala-3/cats/tests/NullableSuite.scala | 26 +- 2 files changed, 434 insertions(+), 186 deletions(-) diff --git a/core/src/main/scala-3/cats/data/Nullable.scala b/core/src/main/scala-3/cats/data/Nullable.scala index 84a1007778..04957988c5 100644 --- a/core/src/main/scala-3/cats/data/Nullable.scala +++ b/core/src/main/scala-3/cats/data/Nullable.scala @@ -1,5 +1,5 @@ /* - * Copyright (c) 2026 Typelevel + * Copyright (c) 2015 Typelevel * * Permission is hereby granted, free of charge, to any person obtaining a copy of * this software and associated documentation files (the "Software"), to deal in @@ -34,67 +34,102 @@ object Nullable extends NullableInstances { private[data] given [A]: CanEqual[A | Null, Null] = CanEqual.derived extension [A](inline nullable: Nullable[A]) { - inline def fold[B](inline ifNull: => B)(inline f: A => B): B = + inline def fold[B](inline ifNull: => B)(inline f: A => B): B = { ${ foldImpl('nullable, 'ifNull, 'f) } + } inline def isNull: Boolean = { val value: A | Null = nullable value == null } - inline def nonNull: Boolean = + inline def nonNull: Boolean = { !isNull + } - inline def map[B](inline f: A => B): Nullable[B] = + inline def map[B](inline f: A => B): Nullable[B] = { fold(null: Null)(f) + } - inline def orNull: A | Null = + inline def orNull: A | Null = { nullable + } - inline def toOption: Option[A] = + inline def toOption: Option[A] = { fold(None)(Some(_)) + } - inline def iterator: Iterator[A] = + inline def iterator: Iterator[A] = { fold(Iterator.empty)(Iterator.single(_)) + } } - inline def apply[A](inline a: A | Null): Nullable[A] = + inline def apply[A](inline a: A | Null): Nullable[A] = { a + } - def fromOption[A](opt: Option[A]): Nullable[A] = + def fromOption[A](opt: Option[A]): Nullable[A] = { opt match { case Some(a) => a case None => null } + } - inline def empty[A]: Nullable[A] = + inline def empty[A]: Nullable[A] = { null + } + + // Non-inline typeclass methods in this same file cannot call `n.toOption` / `n.fold` + // because `fold` is a macro defined in the same source file. + private inline def nonMacroToOption[A](n: Nullable[A]): Option[A] = { + val value: A | Null = n + if value == null then { + None + } else { + Some(value.asInstanceOf[A]) + } + } private[data] def toOptionNullable[A](nullable: Nullable[A]): Option[Nullable[A]] = { val value: A | Null = nullable - if (value == null) None else Some(nullable) + if value == null then { + None + } else { + Some(nullable) + } } private[data] def eqvNullable[A](na: Nullable[A], nb: Nullable[A])(using A: Eq[A]): Boolean = { val a: A | Null = na val b: A | Null = nb - if (a == null) b == null - else if (b == null) false - else A.eqv(a.asInstanceOf[A], b.asInstanceOf[A]) + if a == null then { + b == null + } else if b == null then { + false + } else { + A.eqv(a.asInstanceOf[A], b.asInstanceOf[A]) + } } private[data] def hashNullable[A](nullable: Nullable[A])(using A: Hash[A]): Int = { val value: A | Null = nullable - if (value == null) None.hashCode() - else A.hash(value.asInstanceOf[A]) + if value == null then { + None.hashCode() + } else { + A.hash(value.asInstanceOf[A]) + } } private[data] def partialCompareNullable[A](na: Nullable[A], nb: Nullable[A])(using A: PartialOrder[A]): Double = { val a: A | Null = na val b: A | Null = nb - if (a == null) { - if (b == null) 0.0 else -1.0 - } else if (b == null) { + if a == null then { + if b == null then { + 0.0 + } else { + -1.0 + } + } else if b == null then { 1.0 } else { A.partialCompare(a.asInstanceOf[A], b.asInstanceOf[A]) @@ -104,9 +139,13 @@ object Nullable extends NullableInstances { private[data] def compareNullable[A](na: Nullable[A], nb: Nullable[A])(using A: Order[A]): Int = { val a: A | Null = na val b: A | Null = nb - if (a == null) { - if (b == null) 0 else -1 - } else if (b == null) { + if a == null then { + if b == null then { + 0 + } else { + -1 + } + } else if b == null then { 1 } else { A.compare(a.asInstanceOf[A], b.asInstanceOf[A]) @@ -116,29 +155,38 @@ object Nullable extends NullableInstances { private def combineNullable[A](nx: Nullable[A], ny: Nullable[A])(using A: Semigroup[A]): Nullable[A] = { val x: A | Null = nx val y: A | Null = ny - if (x == null) y - else if (y == null) x - else A.combine(x.asInstanceOf[A], y.asInstanceOf[A]) + if x == null then { + y + } else if y == null then { + x + } else { + A.combine(x.asInstanceOf[A], y.asInstanceOf[A]) + } } given [A](using A: Semigroup[A]): Monoid[Nullable[A]] with { - def empty: Nullable[A] = + def empty: Nullable[A] = { Nullable.empty + } - def combine(nx: Nullable[A], ny: Nullable[A]): Nullable[A] = + def combine(nx: Nullable[A], ny: Nullable[A]): Nullable[A] = { combineNullable(nx, ny) + } } given [A](using A: Show[A]): Show[Nullable[A]] with { def show(nullable: Nullable[A]): String = { val value: A | Null = nullable - if (value == null) java.lang.String.valueOf(value.asInstanceOf[AnyRef]).asInstanceOf[String] - else A.show(value.asInstanceOf[A]) + if value == null then { + java.lang.String.valueOf(value.asInstanceOf[AnyRef]).asInstanceOf[String] + } else { + A.show(value.asInstanceOf[A]) + } } } /* - * These methods intentionally use direct null checks instead of `fa.fold(...)`. + * These methods intentionally use direct null checks or `nonMacroToOption` instead of `fa.fold(...)`. * `fold` is implemented as an inline macro in this same source file, and Scala 3 * rejects calls from non-inline methods to a macro defined in the same file. * (`Traverse` methods cannot be inline overrides here.) @@ -147,258 +195,428 @@ object Nullable extends NullableInstances { private[this] val nullableUnit: Nullable[Unit] = () private[this] val nullPair: (Null, Null) = (null, null) - override def map[A, B](fa: Nullable[A])(f: A => B): Nullable[B] = + override def map[A, B](fa: Nullable[A])(f: A => B): Nullable[B] = { val value: A | Null = fa - if (value == null) Nullable.empty else f(value.asInstanceOf[A]) + if value == null then { + Nullable.empty + } else { + f(value.asInstanceOf[A]) + } + } - override def as[A, B](fa: Nullable[A], b: B): Nullable[B] = + override def as[A, B](fa: Nullable[A], b: B): Nullable[B] = { val value: A | Null = fa - if (value == null) Nullable.empty else b + if value == null then { + Nullable.empty + } else { + b + } + } - override def void[A](fa: Nullable[A]): Nullable[Unit] = + override def void[A](fa: Nullable[A]): Nullable[Unit] = { val value: A | Null = fa - if (value == null) Nullable.empty else nullableUnit + if value == null then { + Nullable.empty + } else { + nullableUnit + } + } - override def tupleLeft[A, B](fa: Nullable[A], b: B): Nullable[(B, A)] = + override def tupleLeft[A, B](fa: Nullable[A], b: B): Nullable[(B, A)] = { val value: A | Null = fa - if (value == null) Nullable.empty else (b, value.asInstanceOf[A]) + if value == null then { + Nullable.empty + } else { + (b, value.asInstanceOf[A]) + } + } - override def tupleRight[A, B](fa: Nullable[A], b: B): Nullable[(A, B)] = + override def tupleRight[A, B](fa: Nullable[A], b: B): Nullable[(A, B)] = { val value: A | Null = fa - if (value == null) Nullable.empty else (value.asInstanceOf[A], b) + if value == null then { + Nullable.empty + } else { + (value.asInstanceOf[A], b) + } + } - override def fproduct[A, B](fa: Nullable[A])(f: A => B): Nullable[(A, B)] = + override def fproduct[A, B](fa: Nullable[A])(f: A => B): Nullable[(A, B)] = { val value: A | Null = fa - if (value == null) Nullable.empty - else { + if value == null then { + Nullable.empty + } else { val a = value.asInstanceOf[A] (a, f(a)) } + } - override def fproductLeft[A, B](fa: Nullable[A])(f: A => B): Nullable[(B, A)] = + override def fproductLeft[A, B](fa: Nullable[A])(f: A => B): Nullable[(B, A)] = { val value: A | Null = fa - if (value == null) Nullable.empty - else { + if value == null then { + Nullable.empty + } else { val a = value.asInstanceOf[A] (f(a), a) } + } - override def ifF[A](fb: Nullable[Boolean])(ifTrue: => A, ifFalse: => A): Nullable[A] = + override def ifF[A](fb: Nullable[Boolean])(ifTrue: => A, ifFalse: => A): Nullable[A] = { val value: Boolean | Null = fb - if (value == null) Nullable.empty - else if (value.asInstanceOf[Boolean]) ifTrue - else ifFalse + if value == null then { + Nullable.empty + } else if value.asInstanceOf[Boolean] then { + ifTrue + } else { + ifFalse + } + } - override def unzip[A, B](fab: Nullable[(A, B)]): (Nullable[A], Nullable[B]) = + override def unzip[A, B](fab: Nullable[(A, B)]): (Nullable[A], Nullable[B]) = { val value: (A, B) | Null = fab - if (value == null) nullPair - else { - val pair = value.asInstanceOf[(A, B)] - pair + if value == null then { + nullPair + } else { + value.asInstanceOf[(A, B)] } + } - def foldLeft[A, B](fa: Nullable[A], b: B)(f: (B, A) => B): B = + def foldLeft[A, B](fa: Nullable[A], b: B)(f: (B, A) => B): B = { val value: A | Null = fa - if (value == null) b else f(b, value.asInstanceOf[A]) + if value == null then { + b + } else { + f(b, value.asInstanceOf[A]) + } + } - def foldRight[A, B](fa: Nullable[A], lb: Eval[B])(f: (A, Eval[B]) => Eval[B]): Eval[B] = + def foldRight[A, B](fa: Nullable[A], lb: Eval[B])(f: (A, Eval[B]) => Eval[B]): Eval[B] = { val value: A | Null = fa - if (value == null) lb else f(value.asInstanceOf[A], lb) + if value == null then { + lb + } else { + f(value.asInstanceOf[A], lb) + } + } - override def fold[A](fa: Nullable[A])(using A: Monoid[A]): A = + override def fold[A](fa: Nullable[A])(using A: Monoid[A]): A = { val value: A | Null = fa - if (value == null) A.empty else value.asInstanceOf[A] + if value == null then { + A.empty + } else { + value.asInstanceOf[A] + } + } - override def combineAllOption[A](fa: Nullable[A])(using Semigroup[A]): Option[A] = - val value: A | Null = fa - if (value == null) None else Some(value.asInstanceOf[A]) + override def combineAllOption[A](fa: Nullable[A])(using Semigroup[A]): Option[A] = { + nonMacroToOption(fa) + } - override def toIterable[A](fa: Nullable[A]): Iterable[A] = + override def toIterable[A](fa: Nullable[A]): Iterable[A] = { val value: A | Null = fa - if (value == null) Iterable.empty else Iterable.single(value.asInstanceOf[A]) + if value == null then { + Iterable.empty + } else { + Iterable.single(value.asInstanceOf[A]) + } + } - override def foldMap[A, B](fa: Nullable[A])(f: A => B)(using B: Monoid[B]): B = + override def foldMap[A, B](fa: Nullable[A])(f: A => B)(using B: Monoid[B]): B = { val value: A | Null = fa - if (value == null) B.empty else f(value.asInstanceOf[A]) + if value == null then { + B.empty + } else { + f(value.asInstanceOf[A]) + } + } - override def reduceLeftToOption[A, B](fa: Nullable[A])(f: A => B)(g: (B, A) => B): Option[B] = - val value: A | Null = fa - if (value == null) None else Some(f(value.asInstanceOf[A])) + override def reduceLeftToOption[A, B](fa: Nullable[A])(f: A => B)(g: (B, A) => B): Option[B] = { + nonMacroToOption(fa).map(f) + } override def reduceRightToOption[A, B](fa: Nullable[A])(f: A => B)(g: (A, Eval[B]) => Eval[B]): Eval[Option[B]] = { - val value: A | Null = fa - if (value == null) Eval.now(None) else Eval.now(Some(f(value.asInstanceOf[A]))) + Eval.now(nonMacroToOption(fa).map(f)) } - override def reduceLeftOption[A](fa: Nullable[A])(f: (A, A) => A): Option[A] = - val value: A | Null = fa - if (value == null) None else Some(value.asInstanceOf[A]) + override def reduceLeftOption[A](fa: Nullable[A])(f: (A, A) => A): Option[A] = { + nonMacroToOption(fa) + } override def reduceRightOption[A](fa: Nullable[A])(f: (A, Eval[A]) => Eval[A]): Eval[Option[A]] = { - val value: A | Null = fa - if (value == null) Eval.now(None) else Eval.now(Some(value.asInstanceOf[A])) + Eval.now(nonMacroToOption(fa)) } - override def minimumOption[A](fa: Nullable[A])(using Order[A]): Option[A] = - val value: A | Null = fa - if (value == null) None else Some(value.asInstanceOf[A]) + override def minimumOption[A](fa: Nullable[A])(using Order[A]): Option[A] = { + nonMacroToOption(fa) + } - override def maximumOption[A](fa: Nullable[A])(using Order[A]): Option[A] = - val value: A | Null = fa - if (value == null) None else Some(value.asInstanceOf[A]) + override def maximumOption[A](fa: Nullable[A])(using Order[A]): Option[A] = { + nonMacroToOption(fa) + } - override def get[A](fa: Nullable[A])(idx: Long): Option[A] = - if (idx != 0L) None - else { - val value: A | Null = fa - if (value == null) None else Some(value.asInstanceOf[A]) + override def get[A](fa: Nullable[A])(idx: Long): Option[A] = { + if idx != 0L then { + None + } else { + nonMacroToOption(fa) } + } override def contains_[A](fa: Nullable[A], v: A)(using A: Eq[A]): Boolean = { val value: A | Null = fa - if (value == null) false else A.eqv(value.asInstanceOf[A], v) + if value == null then { + false + } else { + A.eqv(value.asInstanceOf[A], v) + } } - override def size[A](fa: Nullable[A]): Long = + override def size[A](fa: Nullable[A]): Long = { val value: A | Null = fa - if (value == null) 0L else 1L + if value == null then { + 0L + } else { + 1L + } + } - override def find[A](fa: Nullable[A])(f: A => Boolean): Option[A] = + override def find[A](fa: Nullable[A])(f: A => Boolean): Option[A] = { val value: A | Null = fa - if (value == null) None - else { + if value == null then { + None + } else { val a = value.asInstanceOf[A] - if (f(a)) Some(a) else None + if f(a) then { + Some(a) + } else { + None + } } + } - override def exists[A](fa: Nullable[A])(p: A => Boolean): Boolean = + override def exists[A](fa: Nullable[A])(p: A => Boolean): Boolean = { val value: A | Null = fa - if (value == null) false else p(value.asInstanceOf[A]) + if value == null then { + false + } else { + p(value.asInstanceOf[A]) + } + } - override def forall[A](fa: Nullable[A])(p: A => Boolean): Boolean = + override def forall[A](fa: Nullable[A])(p: A => Boolean): Boolean = { val value: A | Null = fa - if (value == null) true else p(value.asInstanceOf[A]) + if value == null then { + true + } else { + p(value.asInstanceOf[A]) + } + } - override def toList[A](fa: Nullable[A]): List[A] = + override def toList[A](fa: Nullable[A]): List[A] = { val value: A | Null = fa - if (value == null) Nil else value.asInstanceOf[A] :: Nil + if value == null then { + Nil + } else { + value.asInstanceOf[A] :: Nil + } + } - override def filter_[A](fa: Nullable[A])(p: A => Boolean): List[A] = + override def filter_[A](fa: Nullable[A])(p: A => Boolean): List[A] = { val value: A | Null = fa - if (value == null) Nil - else { + if value == null then { + Nil + } else { val a = value.asInstanceOf[A] - if (p(a)) a :: Nil else Nil + if p(a) then { + a :: Nil + } else { + Nil + } } + } - override def takeWhile_[A](fa: Nullable[A])(p: A => Boolean): List[A] = + override def takeWhile_[A](fa: Nullable[A])(p: A => Boolean): List[A] = { val value: A | Null = fa - if (value == null) Nil - else { + if value == null then { + Nil + } else { val a = value.asInstanceOf[A] - if (p(a)) a :: Nil else Nil + if p(a) then { + a :: Nil + } else { + Nil + } } + } - override def dropWhile_[A](fa: Nullable[A])(p: A => Boolean): List[A] = + override def dropWhile_[A](fa: Nullable[A])(p: A => Boolean): List[A] = { val value: A | Null = fa - if (value == null) Nil - else { + if value == null then { + Nil + } else { val a = value.asInstanceOf[A] - if (p(a)) Nil else a :: Nil + if p(a) then { + Nil + } else { + a :: Nil + } } + } - override def isEmpty[A](fa: Nullable[A]): Boolean = + override def isEmpty[A](fa: Nullable[A]): Boolean = { val value: A | Null = fa value == null + } - override def nonEmpty[A](fa: Nullable[A]): Boolean = + override def nonEmpty[A](fa: Nullable[A]): Boolean = { !isEmpty(fa) + } - override def collectFirst[A, B](fa: Nullable[A])(pf: PartialFunction[A, B]): Option[B] = + override def collectFirst[A, B](fa: Nullable[A])(pf: PartialFunction[A, B]): Option[B] = { val value: A | Null = fa - if (value == null) None - else { + if value == null then { + None + } else { val a = value.asInstanceOf[A] - if (pf.isDefinedAt(a)) Some(pf(a)) else None + if pf.isDefinedAt(a) then { + Some(pf(a)) + } else { + None + } } + } - override def collectFirstSome[A, B](fa: Nullable[A])(f: A => Option[B]): Option[B] = + override def collectFirstSome[A, B](fa: Nullable[A])(f: A => Option[B]): Option[B] = { val value: A | Null = fa - if (value == null) None else f(value.asInstanceOf[A]) + if value == null then { + None + } else { + f(value.asInstanceOf[A]) + } + } - override def traverseVoid[G[_], A, B](fa: Nullable[A])(f: A => G[B])(using G: Applicative[G]): G[Unit] = + override def traverseVoid[G[_], A, B](fa: Nullable[A])(f: A => G[B])(using G: Applicative[G]): G[Unit] = { val value: A | Null = fa - if (value == null) G.unit else G.void(f(value.asInstanceOf[A])) + if value == null then { + G.unit + } else { + G.void(f(value.asInstanceOf[A])) + } + } - def traverse[G[_], A, B](fa: Nullable[A])(f: A => G[B])(using G: Applicative[G]): G[Nullable[B]] = + def traverse[G[_], A, B](fa: Nullable[A])(f: A => G[B])(using G: Applicative[G]): G[Nullable[B]] = { val value: A | Null = fa - if (value == null) G.pure(Nullable.empty[B]) - else { + if value == null then { + G.pure(Nullable.empty[B]) + } else { val gb: G[B] = f(value.asInstanceOf[A]) // We can treat `Nullable[B]` as `B | Null`; `widen` is typically a no-op // for lawful/correctly implemented Functors. val gNullable: G[Nullable[B]] = G.widen[B, B | Null](gb) gNullable } + } - override def traverseTap[G[_], A, B](fa: Nullable[A])(f: A => G[B])(using G: Applicative[G]): G[Nullable[A]] = + override def traverseTap[G[_], A, B](fa: Nullable[A])(f: A => G[B])(using G: Applicative[G]): G[Nullable[A]] = { val value: A | Null = fa - if (value == null) G.pure(Nullable.empty[A]) - else { + if value == null then { + G.pure(Nullable.empty[A]) + } else { val a = value.asInstanceOf[A] G.as(f(a), a) } + } - override def sequence[G[_], A](fga: Nullable[G[A]])(using G: Applicative[G]): G[Nullable[A]] = + override def sequence[G[_], A](fga: Nullable[G[A]])(using G: Applicative[G]): G[Nullable[A]] = { val value: G[A] | Null = fga - if (value == null) G.pure(Nullable.empty[A]) - else G.widen[A, A | Null](value.asInstanceOf[G[A]]) + if value == null then { + G.pure(Nullable.empty[A]) + } else { + G.widen[A, A | Null](value.asInstanceOf[G[A]]) + } + } - override def mapAccumulate[S, A, B](init: S, fa: Nullable[A])(f: (S, A) => (S, B)): (S, Nullable[B]) = + override def mapAccumulate[S, A, B](init: S, fa: Nullable[A])(f: (S, A) => (S, B)): (S, Nullable[B]) = { val value: A | Null = fa - if (value == null) (init, Nullable.empty[B]) - else { + if value == null then { + (init, Nullable.empty[B]) + } else { val (next, b) = f(init, value.asInstanceOf[A]) (next, b) } + } - override def mapWithIndex[A, B](fa: Nullable[A])(f: (A, Int) => B): Nullable[B] = + override def mapWithIndex[A, B](fa: Nullable[A])(f: (A, Int) => B): Nullable[B] = { val value: A | Null = fa - if (value == null) Nullable.empty else f(value.asInstanceOf[A], 0) + if value == null then { + Nullable.empty + } else { + f(value.asInstanceOf[A], 0) + } + } - override def traverseWithIndexM[G[_], A, B](fa: Nullable[A])(f: (A, Int) => G[B])(using G: Monad[G]): G[Nullable[B]] = + override def traverseWithIndexM[G[_], A, B]( + fa: Nullable[A] + )(f: (A, Int) => G[B])(using G: Monad[G]): G[Nullable[B]] = { val value: A | Null = fa - if (value == null) G.pure(Nullable.empty[B]) - else G.widen[B, B | Null](f(value.asInstanceOf[A], 0)) + if value == null then { + G.pure(Nullable.empty[B]) + } else { + G.widen[B, B | Null](f(value.asInstanceOf[A], 0)) + } + } - override def zipWithIndex[A](fa: Nullable[A]): Nullable[(A, Int)] = + override def zipWithIndex[A](fa: Nullable[A]): Nullable[(A, Int)] = { val value: A | Null = fa - if (value == null) Nullable.empty else (value.asInstanceOf[A], 0) + if value == null then { + Nullable.empty + } else { + (value.asInstanceOf[A], 0) + } + } override def traverseWithLongIndexM[G[_], A, B]( - fa: Nullable[A] + fa: Nullable[A] )(f: (A, Long) => G[B])(using G: Monad[G]): G[Nullable[B]] = { val value: A | Null = fa - if (value == null) G.pure(Nullable.empty[B]) - else G.widen[B, B | Null](f(value.asInstanceOf[A], 0L)) + if value == null then { + G.pure(Nullable.empty[B]) + } else { + G.widen[B, B | Null](f(value.asInstanceOf[A], 0L)) + } } - override def mapWithLongIndex[A, B](fa: Nullable[A])(f: (A, Long) => B): Nullable[B] = + override def mapWithLongIndex[A, B](fa: Nullable[A])(f: (A, Long) => B): Nullable[B] = { val value: A | Null = fa - if (value == null) Nullable.empty else f(value.asInstanceOf[A], 0L) + if value == null then { + Nullable.empty + } else { + f(value.asInstanceOf[A], 0L) + } + } - override def zipWithLongIndex[A](fa: Nullable[A]): Nullable[(A, Long)] = + override def zipWithLongIndex[A](fa: Nullable[A]): Nullable[(A, Long)] = { val value: A | Null = fa - if (value == null) Nullable.empty else (value.asInstanceOf[A], 0L) + if value == null then { + Nullable.empty + } else { + (value.asInstanceOf[A], 0L) + } + } - override def updated_[A, B >: A](fa: Nullable[A], idx: Long, b: B): Option[Nullable[B]] = - if (idx < 0L) None - else { + override def updated_[A, B >: A](fa: Nullable[A], idx: Long, b: B): Option[Nullable[B]] = { + if idx < 0L then { + None + } else { val value: A | Null = fa - if (value == null) None - else if (idx == 0L) Some(b: Nullable[B]) - else None + if value == null then { + None + } else if idx == 0L then { + Some(b: Nullable[B]) + } else { + None + } } + } } /* @@ -412,17 +630,18 @@ object Nullable extends NullableInstances { * https://github.com/scala/scala3/issues/25208 */ private def foldImpl[T: Type, A: Type]( - nullable: Expr[Nullable[T]], - ifNull: Expr[A], - fn: Expr[T => A] + nullable: Expr[Nullable[T]], + ifNull: Expr[A], + fn: Expr[T => A] )(using Quotes): Expr[A] = { val nullableUnion: Expr[T | Null] = '{ $nullable.asInstanceOf[T | Null] } '{ val n = $nullableUnion given CanEqual[T | Null, Null] = CanEqual.derived - if (n == null) $ifNull - else { + if n == null then { + $ifNull + } else { val safe: T = n.asInstanceOf[T] ${ Expr.betaReduce('{ $fn(safe) }) } } @@ -432,53 +651,76 @@ object Nullable extends NullableInstances { sealed abstract private[data] class NullableInstances extends NullableInstances0 { given [A](using A: Order[A]): Order[Nullable[A]] with { - def compare(na: Nullable[A], nb: Nullable[A]): Int = + def compare(na: Nullable[A], nb: Nullable[A]): Int = { Nullable.compareNullable(na, nb) + } - override def pmin(na: Nullable[A], nb: Nullable[A]): Option[Nullable[A]] = - if (compare(na, nb) <= 0) Nullable.toOptionNullable(na) else Nullable.toOptionNullable(nb) + override def pmin(na: Nullable[A], nb: Nullable[A]): Option[Nullable[A]] = { + if compare(na, nb) <= 0 then { + Nullable.toOptionNullable(na) + } else { + Nullable.toOptionNullable(nb) + } + } - override def pmax(na: Nullable[A], nb: Nullable[A]): Option[Nullable[A]] = - if (compare(na, nb) >= 0) Nullable.toOptionNullable(na) else Nullable.toOptionNullable(nb) + override def pmax(na: Nullable[A], nb: Nullable[A]): Option[Nullable[A]] = { + if compare(na, nb) >= 0 then { + Nullable.toOptionNullable(na) + } else { + Nullable.toOptionNullable(nb) + } + } } } private[data] trait NullableInstances0 extends NullableInstances1 { given [A](using A: PartialOrder[A]): PartialOrder[Nullable[A]] with { - def partialCompare(na: Nullable[A], nb: Nullable[A]): Double = + def partialCompare(na: Nullable[A], nb: Nullable[A]): Double = { Nullable.partialCompareNullable(na, nb) + } override def pmin(na: Nullable[A], nb: Nullable[A]): Option[Nullable[A]] = { val c = partialCompare(na, nb) // Avoid boxing by calling the primitive static function. - if (java.lang.Double.isNaN(c)) None - else if (c <= 0.0) Nullable.toOptionNullable(na) - else Nullable.toOptionNullable(nb) + if java.lang.Double.isNaN(c) then { + None + } else if c <= 0.0 then { + Nullable.toOptionNullable(na) + } else { + Nullable.toOptionNullable(nb) + } } override def pmax(na: Nullable[A], nb: Nullable[A]): Option[Nullable[A]] = { val c = partialCompare(na, nb) // Avoid boxing by calling the primitive static function. - if (java.lang.Double.isNaN(c)) None - else if (c >= 0.0) Nullable.toOptionNullable(na) - else Nullable.toOptionNullable(nb) + if java.lang.Double.isNaN(c) then { + None + } else if c >= 0.0 then { + Nullable.toOptionNullable(na) + } else { + Nullable.toOptionNullable(nb) + } } } } private[data] trait NullableInstances1 extends NullableInstances2 { given [A](using A: Hash[A]): Hash[Nullable[A]] with { - def eqv(na: Nullable[A], nb: Nullable[A]): Boolean = + def eqv(na: Nullable[A], nb: Nullable[A]): Boolean = { Nullable.eqvNullable(na, nb) + } - def hash(nullable: Nullable[A]): Int = + def hash(nullable: Nullable[A]): Int = { Nullable.hashNullable(nullable) + } } } private[data] trait NullableInstances2 { given [A](using A: Eq[A]): Eq[Nullable[A]] with { - def eqv(na: Nullable[A], nb: Nullable[A]): Boolean = + def eqv(na: Nullable[A], nb: Nullable[A]): Boolean = { Nullable.eqvNullable(na, nb) + } } } diff --git a/tests/shared/src/test/scala-3/cats/tests/NullableSuite.scala b/tests/shared/src/test/scala-3/cats/tests/NullableSuite.scala index 8343c20444..3f5418f4ef 100644 --- a/tests/shared/src/test/scala-3/cats/tests/NullableSuite.scala +++ b/tests/shared/src/test/scala-3/cats/tests/NullableSuite.scala @@ -1,5 +1,5 @@ /* - * Copyright (c) 2026 Typelevel + * Copyright (c) 2015 Typelevel * * Permission is hereby granted, free of charge, to any person obtaining a copy of * this software and associated documentation files (the "Software"), to deal in @@ -205,13 +205,15 @@ class NullableSuite extends CatsSuite { val toLong: Int => Long = _.toLong + 1L val sumLong: (Long, Int) => Long = (acc, i) => acc + i.toLong val pfEven: PartialFunction[Int, Int] = { case i if i % 2 == 0 => i + 10 } - val firstSome: Int => Option[Int] = i => if (i % 2 == 0) Some(i + 1) else None - val traverseVoidF: Int => Option[Int] = i => if (i % 2 == 0) Some(i) else None + val firstSome: Int => Option[Int] = i => if i % 2 == 0 then Some(i + 1) else None + val traverseVoidF: Int => Option[Int] = i => if i % 2 == 0 then Some(i) else None forAll { (fa: Nullable[Int], idx: Long, target: Int, pred: Int => Boolean) => assert(optimized.foldLeft(fa, 1)(_ + _) === defaults.foldLeft(fa, 1)(_ + _)) - assert(optimized.foldRight(fa, Eval.now(1))((a, eb) => eb.map(_ + a)).value === - defaults.foldRight(fa, Eval.now(1))((a, eb) => eb.map(_ + a)).value) + assert( + optimized.foldRight(fa, Eval.now(1))((a, eb) => eb.map(_ + a)).value === + defaults.foldRight(fa, Eval.now(1))((a, eb) => eb.map(_ + a)).value + ) assert(optimized.fold(fa) === defaults.fold(fa)) assert(optimized.combineAllOption(fa) === defaults.combineAllOption(fa)) assert(optimized.toIterable(fa).toList === defaults.toIterable(fa).toList) @@ -251,11 +253,11 @@ class NullableSuite extends CatsSuite { val defaults = defaultOnlyTraverse forAll { (fa: Nullable[Int], fga: Nullable[Option[Int]], init: Long, replacement: Int, idx: Long) => - val tapF: Int => Option[String] = i => if (i % 2 == 0) Some(i.toString) else None + val tapF: Int => Option[String] = i => if i % 2 == 0 then Some(i.toString) else None val accumulateF: (Long, Int) => (Long, String) = (s, i) => (s + i.toLong, s"$s:$i") - val indexTraverseF: (Int, Int) => Option[String] = (i, n) => if ((i + n) % 2 == 0) Some(s"$i-$n") else None - val longIndexTraverseF: (Int, Long) => Option[String] = (i, n) => - if ((i.toLong + n) % 2L == 0L) Some(s"$i-$n") else None + val indexTraverseF: (Int, Int) => Option[String] = (i, n) => if (i + n) % 2 == 0 then Some(s"$i-$n") else None + val longIndexTraverseF: (Int, Long) => Option[String] = + (i, n) => if (i.toLong + n) % 2L == 0L then Some(s"$i-$n") else None val indexMapF: (Int, Int) => String = (i, n) => s"$i@$n" val longIndexMapF: (Int, Long) => String = (i, n) => s"$i@$n" @@ -266,7 +268,11 @@ class NullableSuite extends CatsSuite { assert(optimized.mapWithIndex(fa)(indexMapF) === defaults.mapWithIndex(fa)(indexMapF)) assert(optimized.traverseWithIndexM(fa)(indexTraverseF) === defaults.traverseWithIndexM(fa)(indexTraverseF)) assert(optimized.zipWithIndex(fa) === defaults.zipWithIndex(fa)) - assert(optimized.traverseWithLongIndexM(fa)(longIndexTraverseF) === defaults.traverseWithLongIndexM(fa)(longIndexTraverseF)) + assert( + optimized.traverseWithLongIndexM(fa)(longIndexTraverseF) === defaults.traverseWithLongIndexM(fa)( + longIndexTraverseF + ) + ) assert(optimized.mapWithLongIndex(fa)(longIndexMapF) === defaults.mapWithLongIndex(fa)(longIndexMapF)) assert(optimized.zipWithLongIndex(fa) === defaults.zipWithLongIndex(fa)) assert(optimized.updated_(fa, idx, replacement) === defaults.updated_(fa, idx, replacement)) From 700942c2f115686ddf90f20204af970a9f181fe0 Mon Sep 17 00:00:00 2001 From: Oscar Boykin Date: Mon, 16 Feb 2026 08:57:13 -1000 Subject: [PATCH 3/6] remove toOptionNullable --- .../src/main/scala-3/cats/data/Nullable.scala | 29 +++++++------------ 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/core/src/main/scala-3/cats/data/Nullable.scala b/core/src/main/scala-3/cats/data/Nullable.scala index 04957988c5..83b966a543 100644 --- a/core/src/main/scala-3/cats/data/Nullable.scala +++ b/core/src/main/scala-3/cats/data/Nullable.scala @@ -81,7 +81,7 @@ object Nullable extends NullableInstances { // Non-inline typeclass methods in this same file cannot call `n.toOption` / `n.fold` // because `fold` is a macro defined in the same source file. - private inline def nonMacroToOption[A](n: Nullable[A]): Option[A] = { + private[data] inline def nonMacroToOption[A](n: Nullable[A]): Option[A] = { val value: A | Null = n if value == null then { None @@ -90,15 +90,6 @@ object Nullable extends NullableInstances { } } - private[data] def toOptionNullable[A](nullable: Nullable[A]): Option[Nullable[A]] = { - val value: A | Null = nullable - if value == null then { - None - } else { - Some(nullable) - } - } - private[data] def eqvNullable[A](na: Nullable[A], nb: Nullable[A])(using A: Eq[A]): Boolean = { val a: A | Null = na val b: A | Null = nb @@ -178,7 +169,7 @@ object Nullable extends NullableInstances { def show(nullable: Nullable[A]): String = { val value: A | Null = nullable if value == null then { - java.lang.String.valueOf(value.asInstanceOf[AnyRef]).asInstanceOf[String] + java.lang.String.valueOf(null) } else { A.show(value.asInstanceOf[A]) } @@ -657,17 +648,17 @@ sealed abstract private[data] class NullableInstances extends NullableInstances0 override def pmin(na: Nullable[A], nb: Nullable[A]): Option[Nullable[A]] = { if compare(na, nb) <= 0 then { - Nullable.toOptionNullable(na) + Nullable.nonMacroToOption(na).asInstanceOf[Option[Nullable[A]]] } else { - Nullable.toOptionNullable(nb) + Nullable.nonMacroToOption(nb).asInstanceOf[Option[Nullable[A]]] } } override def pmax(na: Nullable[A], nb: Nullable[A]): Option[Nullable[A]] = { if compare(na, nb) >= 0 then { - Nullable.toOptionNullable(na) + Nullable.nonMacroToOption(na).asInstanceOf[Option[Nullable[A]]] } else { - Nullable.toOptionNullable(nb) + Nullable.nonMacroToOption(nb).asInstanceOf[Option[Nullable[A]]] } } } @@ -685,9 +676,9 @@ private[data] trait NullableInstances0 extends NullableInstances1 { if java.lang.Double.isNaN(c) then { None } else if c <= 0.0 then { - Nullable.toOptionNullable(na) + Nullable.nonMacroToOption(na).asInstanceOf[Option[Nullable[A]]] } else { - Nullable.toOptionNullable(nb) + Nullable.nonMacroToOption(nb).asInstanceOf[Option[Nullable[A]]] } } @@ -697,9 +688,9 @@ private[data] trait NullableInstances0 extends NullableInstances1 { if java.lang.Double.isNaN(c) then { None } else if c >= 0.0 then { - Nullable.toOptionNullable(na) + Nullable.nonMacroToOption(na).asInstanceOf[Option[Nullable[A]]] } else { - Nullable.toOptionNullable(nb) + Nullable.nonMacroToOption(nb).asInstanceOf[Option[Nullable[A]]] } } } From 458dd9c93326d1bb2ec242eb31a5f9634810159b Mon Sep 17 00:00:00 2001 From: Oscar Boykin Date: Mon, 16 Feb 2026 09:03:57 -1000 Subject: [PATCH 4/6] add nullable flatten --- .../src/main/scala-3/cats/data/Nullable.scala | 33 ++++++++++++++----- .../scala-3/cats/tests/NullableSuite.scala | 18 ++++++++++ 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/core/src/main/scala-3/cats/data/Nullable.scala b/core/src/main/scala-3/cats/data/Nullable.scala index 83b966a543..826337075a 100644 --- a/core/src/main/scala-3/cats/data/Nullable.scala +++ b/core/src/main/scala-3/cats/data/Nullable.scala @@ -64,6 +64,12 @@ object Nullable extends NullableInstances { } } + extension [A](inline nested: Nullable[Nullable[A]]) { + inline def flatten: Nullable[A] = { + nested + } + } + inline def apply[A](inline a: A | Null): Nullable[A] = { a } @@ -90,6 +96,15 @@ object Nullable extends NullableInstances { } } + private[data] inline def nonMacroToNullableOption[A](n: Nullable[A]): Option[Nullable[A]] = { + val value: A | Null = n + if value == null then { + None + } else { + Some(n) + } + } + private[data] def eqvNullable[A](na: Nullable[A], nb: Nullable[A])(using A: Eq[A]): Boolean = { val a: A | Null = na val b: A | Null = nb @@ -169,7 +184,7 @@ object Nullable extends NullableInstances { def show(nullable: Nullable[A]): String = { val value: A | Null = nullable if value == null then { - java.lang.String.valueOf(null) + java.lang.String.valueOf(null: AnyRef) } else { A.show(value.asInstanceOf[A]) } @@ -648,17 +663,17 @@ sealed abstract private[data] class NullableInstances extends NullableInstances0 override def pmin(na: Nullable[A], nb: Nullable[A]): Option[Nullable[A]] = { if compare(na, nb) <= 0 then { - Nullable.nonMacroToOption(na).asInstanceOf[Option[Nullable[A]]] + Nullable.nonMacroToNullableOption(na) } else { - Nullable.nonMacroToOption(nb).asInstanceOf[Option[Nullable[A]]] + Nullable.nonMacroToNullableOption(nb) } } override def pmax(na: Nullable[A], nb: Nullable[A]): Option[Nullable[A]] = { if compare(na, nb) >= 0 then { - Nullable.nonMacroToOption(na).asInstanceOf[Option[Nullable[A]]] + Nullable.nonMacroToNullableOption(na) } else { - Nullable.nonMacroToOption(nb).asInstanceOf[Option[Nullable[A]]] + Nullable.nonMacroToNullableOption(nb) } } } @@ -676,9 +691,9 @@ private[data] trait NullableInstances0 extends NullableInstances1 { if java.lang.Double.isNaN(c) then { None } else if c <= 0.0 then { - Nullable.nonMacroToOption(na).asInstanceOf[Option[Nullable[A]]] + Nullable.nonMacroToNullableOption(na) } else { - Nullable.nonMacroToOption(nb).asInstanceOf[Option[Nullable[A]]] + Nullable.nonMacroToNullableOption(nb) } } @@ -688,9 +703,9 @@ private[data] trait NullableInstances0 extends NullableInstances1 { if java.lang.Double.isNaN(c) then { None } else if c >= 0.0 then { - Nullable.nonMacroToOption(na).asInstanceOf[Option[Nullable[A]]] + Nullable.nonMacroToNullableOption(na) } else { - Nullable.nonMacroToOption(nb).asInstanceOf[Option[Nullable[A]]] + Nullable.nonMacroToNullableOption(nb) } } } diff --git a/tests/shared/src/test/scala-3/cats/tests/NullableSuite.scala b/tests/shared/src/test/scala-3/cats/tests/NullableSuite.scala index 3f5418f4ef..539c376aba 100644 --- a/tests/shared/src/test/scala-3/cats/tests/NullableSuite.scala +++ b/tests/shared/src/test/scala-3/cats/tests/NullableSuite.scala @@ -114,6 +114,24 @@ class NullableSuite extends CatsSuite { } } + test("flatten is consistent with Nullable identity and Option view") { + forAll { (nullable: Nullable[Int]) => + val nested: Nullable[Nullable[Int]] = Nullable(nullable) + assert(nested.flatten === nullable) + assert(nested.flatten.toOption === nullable.toOption) + } + } + + test("flatten collapses nested empty and nested non-empty values") { + val nestedEmpty: Nullable[Nullable[Int]] = Nullable(Nullable.empty[Int]) + assert(nestedEmpty.flatten === Nullable.empty[Int]) + + forAll { (x: Int) => + val nestedNonEmpty: Nullable[Nullable[Int]] = Nullable(Nullable(x)) + assert(nestedNonEmpty.flatten === Nullable(x)) + } + } + test("option-like Applicative is not lawful for nested Nullable") { val candidate = new Applicative[Nullable] { def pure[A](x: A): Nullable[A] = Nullable(x) From 00c7029920fd7f40db04aa1405269f1eb8090025 Mon Sep 17 00:00:00 2001 From: Oscar Boykin Date: Thu, 19 Feb 2026 20:08:36 -1000 Subject: [PATCH 5/6] rename map --- .../src/main/scala-3/cats/data/Nullable.scala | 421 +----------------- .../scala-3/cats/tests/NullableSuite.scala | 165 ++----- 2 files changed, 68 insertions(+), 518 deletions(-) diff --git a/core/src/main/scala-3/cats/data/Nullable.scala b/core/src/main/scala-3/cats/data/Nullable.scala index 826337075a..af3d049661 100644 --- a/core/src/main/scala-3/cats/data/Nullable.scala +++ b/core/src/main/scala-3/cats/data/Nullable.scala @@ -21,7 +21,7 @@ package cats.data -import cats.{Applicative, Eq, Eval, Hash, Monad, Monoid, Order, PartialOrder, Semigroup, Show, Traverse} +import cats.{Eq, Eval, Foldable, Hash, Monoid, Order, PartialOrder, Semigroup, Show} import scala.language.strictEquality import scala.quoted.* @@ -47,7 +47,24 @@ object Nullable extends NullableInstances { !isNull } - inline def map[B](inline f: A => B): Nullable[B] = { + /** + * Transforms the non-null value. + * + * This is not a lawful `Functor` map. Because `Nullable[Nullable[A]]` collapses to + * `Nullable[A]`, composition can break: + * + * {{{ + * val n: Nullable[Nullable[Int]] = Nullable(Nullable(1)) + * val f: Int => String = _ => null + * val g: String => Int = _ => 42 + * + * val lhs = n.transform(_.transform(f)).transform(_.transform(g)) + * val rhs = n.transform(_.transform(f.andThen(g))) + * + * // lhs is null, rhs is 42 + * }}} + */ + inline def transform[B](inline f: A => B): Nullable[B] = { fold(null: Null)(f) } @@ -192,100 +209,11 @@ object Nullable extends NullableInstances { } /* - * These methods intentionally use direct null checks or `nonMacroToOption` instead of `fa.fold(...)`. + * These methods intentionally use direct null checks instead of `fa.fold(...)`. * `fold` is implemented as an inline macro in this same source file, and Scala 3 * rejects calls from non-inline methods to a macro defined in the same file. - * (`Traverse` methods cannot be inline overrides here.) */ - given catsDataTraverseForNullable: Traverse[Nullable] with { - private[this] val nullableUnit: Nullable[Unit] = () - private[this] val nullPair: (Null, Null) = (null, null) - - override def map[A, B](fa: Nullable[A])(f: A => B): Nullable[B] = { - val value: A | Null = fa - if value == null then { - Nullable.empty - } else { - f(value.asInstanceOf[A]) - } - } - - override def as[A, B](fa: Nullable[A], b: B): Nullable[B] = { - val value: A | Null = fa - if value == null then { - Nullable.empty - } else { - b - } - } - - override def void[A](fa: Nullable[A]): Nullable[Unit] = { - val value: A | Null = fa - if value == null then { - Nullable.empty - } else { - nullableUnit - } - } - - override def tupleLeft[A, B](fa: Nullable[A], b: B): Nullable[(B, A)] = { - val value: A | Null = fa - if value == null then { - Nullable.empty - } else { - (b, value.asInstanceOf[A]) - } - } - - override def tupleRight[A, B](fa: Nullable[A], b: B): Nullable[(A, B)] = { - val value: A | Null = fa - if value == null then { - Nullable.empty - } else { - (value.asInstanceOf[A], b) - } - } - - override def fproduct[A, B](fa: Nullable[A])(f: A => B): Nullable[(A, B)] = { - val value: A | Null = fa - if value == null then { - Nullable.empty - } else { - val a = value.asInstanceOf[A] - (a, f(a)) - } - } - - override def fproductLeft[A, B](fa: Nullable[A])(f: A => B): Nullable[(B, A)] = { - val value: A | Null = fa - if value == null then { - Nullable.empty - } else { - val a = value.asInstanceOf[A] - (f(a), a) - } - } - - override def ifF[A](fb: Nullable[Boolean])(ifTrue: => A, ifFalse: => A): Nullable[A] = { - val value: Boolean | Null = fb - if value == null then { - Nullable.empty - } else if value.asInstanceOf[Boolean] then { - ifTrue - } else { - ifFalse - } - } - - override def unzip[A, B](fab: Nullable[(A, B)]): (Nullable[A], Nullable[B]) = { - val value: (A, B) | Null = fab - if value == null then { - nullPair - } else { - value.asInstanceOf[(A, B)] - } - } - + given catsDataFoldableForNullable: Foldable[Nullable] with { def foldLeft[A, B](fa: Nullable[A], b: B)(f: (B, A) => B): B = { val value: A | Null = fa if value == null then { @@ -316,313 +244,6 @@ object Nullable extends NullableInstances { override def combineAllOption[A](fa: Nullable[A])(using Semigroup[A]): Option[A] = { nonMacroToOption(fa) } - - override def toIterable[A](fa: Nullable[A]): Iterable[A] = { - val value: A | Null = fa - if value == null then { - Iterable.empty - } else { - Iterable.single(value.asInstanceOf[A]) - } - } - - override def foldMap[A, B](fa: Nullable[A])(f: A => B)(using B: Monoid[B]): B = { - val value: A | Null = fa - if value == null then { - B.empty - } else { - f(value.asInstanceOf[A]) - } - } - - override def reduceLeftToOption[A, B](fa: Nullable[A])(f: A => B)(g: (B, A) => B): Option[B] = { - nonMacroToOption(fa).map(f) - } - - override def reduceRightToOption[A, B](fa: Nullable[A])(f: A => B)(g: (A, Eval[B]) => Eval[B]): Eval[Option[B]] = { - Eval.now(nonMacroToOption(fa).map(f)) - } - - override def reduceLeftOption[A](fa: Nullable[A])(f: (A, A) => A): Option[A] = { - nonMacroToOption(fa) - } - - override def reduceRightOption[A](fa: Nullable[A])(f: (A, Eval[A]) => Eval[A]): Eval[Option[A]] = { - Eval.now(nonMacroToOption(fa)) - } - - override def minimumOption[A](fa: Nullable[A])(using Order[A]): Option[A] = { - nonMacroToOption(fa) - } - - override def maximumOption[A](fa: Nullable[A])(using Order[A]): Option[A] = { - nonMacroToOption(fa) - } - - override def get[A](fa: Nullable[A])(idx: Long): Option[A] = { - if idx != 0L then { - None - } else { - nonMacroToOption(fa) - } - } - - override def contains_[A](fa: Nullable[A], v: A)(using A: Eq[A]): Boolean = { - val value: A | Null = fa - if value == null then { - false - } else { - A.eqv(value.asInstanceOf[A], v) - } - } - - override def size[A](fa: Nullable[A]): Long = { - val value: A | Null = fa - if value == null then { - 0L - } else { - 1L - } - } - - override def find[A](fa: Nullable[A])(f: A => Boolean): Option[A] = { - val value: A | Null = fa - if value == null then { - None - } else { - val a = value.asInstanceOf[A] - if f(a) then { - Some(a) - } else { - None - } - } - } - - override def exists[A](fa: Nullable[A])(p: A => Boolean): Boolean = { - val value: A | Null = fa - if value == null then { - false - } else { - p(value.asInstanceOf[A]) - } - } - - override def forall[A](fa: Nullable[A])(p: A => Boolean): Boolean = { - val value: A | Null = fa - if value == null then { - true - } else { - p(value.asInstanceOf[A]) - } - } - - override def toList[A](fa: Nullable[A]): List[A] = { - val value: A | Null = fa - if value == null then { - Nil - } else { - value.asInstanceOf[A] :: Nil - } - } - - override def filter_[A](fa: Nullable[A])(p: A => Boolean): List[A] = { - val value: A | Null = fa - if value == null then { - Nil - } else { - val a = value.asInstanceOf[A] - if p(a) then { - a :: Nil - } else { - Nil - } - } - } - - override def takeWhile_[A](fa: Nullable[A])(p: A => Boolean): List[A] = { - val value: A | Null = fa - if value == null then { - Nil - } else { - val a = value.asInstanceOf[A] - if p(a) then { - a :: Nil - } else { - Nil - } - } - } - - override def dropWhile_[A](fa: Nullable[A])(p: A => Boolean): List[A] = { - val value: A | Null = fa - if value == null then { - Nil - } else { - val a = value.asInstanceOf[A] - if p(a) then { - Nil - } else { - a :: Nil - } - } - } - - override def isEmpty[A](fa: Nullable[A]): Boolean = { - val value: A | Null = fa - value == null - } - - override def nonEmpty[A](fa: Nullable[A]): Boolean = { - !isEmpty(fa) - } - - override def collectFirst[A, B](fa: Nullable[A])(pf: PartialFunction[A, B]): Option[B] = { - val value: A | Null = fa - if value == null then { - None - } else { - val a = value.asInstanceOf[A] - if pf.isDefinedAt(a) then { - Some(pf(a)) - } else { - None - } - } - } - - override def collectFirstSome[A, B](fa: Nullable[A])(f: A => Option[B]): Option[B] = { - val value: A | Null = fa - if value == null then { - None - } else { - f(value.asInstanceOf[A]) - } - } - - override def traverseVoid[G[_], A, B](fa: Nullable[A])(f: A => G[B])(using G: Applicative[G]): G[Unit] = { - val value: A | Null = fa - if value == null then { - G.unit - } else { - G.void(f(value.asInstanceOf[A])) - } - } - - def traverse[G[_], A, B](fa: Nullable[A])(f: A => G[B])(using G: Applicative[G]): G[Nullable[B]] = { - val value: A | Null = fa - if value == null then { - G.pure(Nullable.empty[B]) - } else { - val gb: G[B] = f(value.asInstanceOf[A]) - // We can treat `Nullable[B]` as `B | Null`; `widen` is typically a no-op - // for lawful/correctly implemented Functors. - val gNullable: G[Nullable[B]] = G.widen[B, B | Null](gb) - gNullable - } - } - - override def traverseTap[G[_], A, B](fa: Nullable[A])(f: A => G[B])(using G: Applicative[G]): G[Nullable[A]] = { - val value: A | Null = fa - if value == null then { - G.pure(Nullable.empty[A]) - } else { - val a = value.asInstanceOf[A] - G.as(f(a), a) - } - } - - override def sequence[G[_], A](fga: Nullable[G[A]])(using G: Applicative[G]): G[Nullable[A]] = { - val value: G[A] | Null = fga - if value == null then { - G.pure(Nullable.empty[A]) - } else { - G.widen[A, A | Null](value.asInstanceOf[G[A]]) - } - } - - override def mapAccumulate[S, A, B](init: S, fa: Nullable[A])(f: (S, A) => (S, B)): (S, Nullable[B]) = { - val value: A | Null = fa - if value == null then { - (init, Nullable.empty[B]) - } else { - val (next, b) = f(init, value.asInstanceOf[A]) - (next, b) - } - } - - override def mapWithIndex[A, B](fa: Nullable[A])(f: (A, Int) => B): Nullable[B] = { - val value: A | Null = fa - if value == null then { - Nullable.empty - } else { - f(value.asInstanceOf[A], 0) - } - } - - override def traverseWithIndexM[G[_], A, B]( - fa: Nullable[A] - )(f: (A, Int) => G[B])(using G: Monad[G]): G[Nullable[B]] = { - val value: A | Null = fa - if value == null then { - G.pure(Nullable.empty[B]) - } else { - G.widen[B, B | Null](f(value.asInstanceOf[A], 0)) - } - } - - override def zipWithIndex[A](fa: Nullable[A]): Nullable[(A, Int)] = { - val value: A | Null = fa - if value == null then { - Nullable.empty - } else { - (value.asInstanceOf[A], 0) - } - } - - override def traverseWithLongIndexM[G[_], A, B]( - fa: Nullable[A] - )(f: (A, Long) => G[B])(using G: Monad[G]): G[Nullable[B]] = { - val value: A | Null = fa - if value == null then { - G.pure(Nullable.empty[B]) - } else { - G.widen[B, B | Null](f(value.asInstanceOf[A], 0L)) - } - } - - override def mapWithLongIndex[A, B](fa: Nullable[A])(f: (A, Long) => B): Nullable[B] = { - val value: A | Null = fa - if value == null then { - Nullable.empty - } else { - f(value.asInstanceOf[A], 0L) - } - } - - override def zipWithLongIndex[A](fa: Nullable[A]): Nullable[(A, Long)] = { - val value: A | Null = fa - if value == null then { - Nullable.empty - } else { - (value.asInstanceOf[A], 0L) - } - } - - override def updated_[A, B >: A](fa: Nullable[A], idx: Long, b: B): Option[Nullable[B]] = { - if idx < 0L then { - None - } else { - val value: A | Null = fa - if value == null then { - None - } else if idx == 0L then { - Some(b: Nullable[B]) - } else { - None - } - } - } } /* diff --git a/tests/shared/src/test/scala-3/cats/tests/NullableSuite.scala b/tests/shared/src/test/scala-3/cats/tests/NullableSuite.scala index 539c376aba..6289242571 100644 --- a/tests/shared/src/test/scala-3/cats/tests/NullableSuite.scala +++ b/tests/shared/src/test/scala-3/cats/tests/NullableSuite.scala @@ -21,32 +21,20 @@ package cats.tests -import cats.{Applicative, Eq, Eval, Hash, Monad, Monoid, Order, PartialOrder, Semigroup, Show, Traverse} +import cats.{Applicative, Eq, Foldable, Functor, Hash, Monad, Monoid, Order, PartialOrder, Semigroup, Show} import cats.data.Nullable import cats.data.Nullable.* import cats.kernel.laws.discipline.{EqTests, HashTests, MonoidTests, OrderTests, PartialOrderTests, SemigroupTests} -import cats.laws.discipline.{SerializableTests, TraverseTests} +import cats.laws.discipline.{FoldableTests, SerializableTests} import cats.syntax.eq.* import org.scalacheck.{Arbitrary, Cogen, Gen} import org.scalacheck.Prop.* class NullableSuite extends CatsSuite { - private val optimizedTraverseForNullable: Traverse[Nullable] = Traverse[Nullable] + private type NestedNullable[A] = Nullable[Nullable[A]] - private val defaultOnlyTraverse: Traverse[Nullable] = new Traverse[Nullable] { - override def map[A, B](fa: Nullable[A])(f: A => B): Nullable[B] = - fa.map(f) - - def foldLeft[A, B](fa: Nullable[A], b: B)(f: (B, A) => B): B = - fa.fold(b)(a => f(b, a)) - - def foldRight[A, B](fa: Nullable[A], lb: Eval[B])(f: (A, Eval[B]) => Eval[B]): Eval[B] = - fa.fold(lb)(a => f(a, lb)) - - def traverse[G[_], A, B](fa: Nullable[A])(f: A => G[B])(using G: Applicative[G]): G[Nullable[B]] = - fa.fold(G.pure(Nullable.empty[B]))(a => G.map(f(a))(Nullable(_))) - } + private given Foldable[NestedNullable] = Foldable[Nullable].compose[Nullable] given [A](using A: Arbitrary[A]): Arbitrary[Nullable[A]] = Arbitrary(Gen.oneOf(Gen.const(Nullable.empty[A]), A.arbitrary.map(Nullable(_)))) @@ -55,27 +43,42 @@ class NullableSuite extends CatsSuite { Cogen[Option[A]].contramap(_.toOption) checkAll("Nullable[Int]", EqTests[Nullable[Int]].eqv) + checkAll("Nullable[Nullable[Int]]", EqTests[NestedNullable[Int]].eqv) checkAll("Eq[Nullable[Int]]", SerializableTests.serializable(Eq[Nullable[Int]])) + checkAll("Eq[Nullable[Nullable[Int]]]", SerializableTests.serializable(Eq[NestedNullable[Int]])) checkAll("Nullable[Int]", HashTests[Nullable[Int]].hash) + checkAll("Nullable[Nullable[Int]]", HashTests[NestedNullable[Int]].hash) checkAll("Hash[Nullable[Int]]", SerializableTests.serializable(Hash[Nullable[Int]])) + checkAll("Hash[Nullable[Nullable[Int]]]", SerializableTests.serializable(Hash[NestedNullable[Int]])) checkAll("Nullable[Int]", PartialOrderTests[Nullable[Int]].partialOrder) + checkAll("Nullable[Nullable[Int]]", PartialOrderTests[NestedNullable[Int]].partialOrder) checkAll("PartialOrder[Nullable[Int]]", SerializableTests.serializable(PartialOrder[Nullable[Int]])) + checkAll( + "PartialOrder[Nullable[Nullable[Int]]]", + SerializableTests.serializable(PartialOrder[NestedNullable[Int]]) + ) checkAll("Nullable[Int]", OrderTests[Nullable[Int]].order) + checkAll("Nullable[Nullable[Int]]", OrderTests[NestedNullable[Int]].order) checkAll("Order[Nullable[Int]]", SerializableTests.serializable(Order[Nullable[Int]])) + checkAll("Order[Nullable[Nullable[Int]]]", SerializableTests.serializable(Order[NestedNullable[Int]])) checkAll("Nullable[Int]", SemigroupTests[Nullable[Int]].semigroup) checkAll("Semigroup[Nullable[Int]]", SerializableTests.serializable(Semigroup[Nullable[Int]])) checkAll("Nullable[Int]", MonoidTests[Nullable[Int]].monoid) + checkAll("Nullable[Nullable[Int]]", MonoidTests[NestedNullable[Int]].monoid) checkAll("Monoid[Nullable[Int]]", SerializableTests.serializable(Monoid[Nullable[Int]])) + checkAll("Monoid[Nullable[Nullable[Int]]]", SerializableTests.serializable(Monoid[NestedNullable[Int]])) checkAll("Show[Nullable[Int]]", SerializableTests.serializable(Show[Nullable[Int]])) + checkAll("Show[Nullable[Nullable[Int]]]", SerializableTests.serializable(Show[NestedNullable[Int]])) - checkAll("Nullable[Int] with Option", TraverseTests[Nullable].traverse[Int, Int, Int, Int, Option, Option]) - checkAll("Traverse[Nullable]", SerializableTests.serializable(Traverse[Nullable])) + checkAll("Foldable[Nullable]", FoldableTests[Nullable].foldable[Int, Int]) + checkAll("Foldable[Nullable].compose[Nullable]", FoldableTests[NestedNullable].foldable[Int, Int]) + checkAll("Foldable[Nullable]", SerializableTests.serializable(Foldable[Nullable])) test("fold") { val nonEmpty: Nullable[Int] = Nullable(1) @@ -132,15 +135,33 @@ class NullableSuite extends CatsSuite { } } + test("minimal Functor[Nullable] fails composition when composed with itself") { + val candidate = new Functor[Nullable] { + def map[A, B](fa: Nullable[A])(f: A => B): Nullable[B] = + fa.transform(f) + } + + val composed: Functor[NestedNullable] = candidate.compose[Nullable](using candidate) + + val nested: NestedNullable[Int] = Nullable(Nullable(1)) + val f: Int => String = _ => null + val g: String => Int = _ => 42 + + val lhs = composed.map(composed.map(nested)(f))(g) + val rhs = composed.map(nested)(f.andThen(g)) + + assert(lhs =!= rhs) + } + test("option-like Applicative is not lawful for nested Nullable") { val candidate = new Applicative[Nullable] { def pure[A](x: A): Nullable[A] = Nullable(x) override def map[A, B](fa: Nullable[A])(f: A => B): Nullable[B] = - fa.map(f) + fa.transform(f) def ap[A, B](ff: Nullable[A => B])(fa: Nullable[A]): Nullable[B] = - ff.fold(Nullable.empty[B])(f => fa.map(f)) + ff.fold(Nullable.empty[B])(f => fa.transform(f)) } val a: Nullable[Int] = Nullable.empty @@ -157,7 +178,7 @@ class NullableSuite extends CatsSuite { def pure[A](x: A): Nullable[A] = Nullable(x) override def map[A, B](fa: Nullable[A])(f: A => B): Nullable[B] = - fa.map(f) + fa.transform(f) def flatMap[A, B](fa: Nullable[A])(f: A => Nullable[B]): Nullable[B] = fa.fold(Nullable.empty[B])(f) @@ -197,103 +218,11 @@ class NullableSuite extends CatsSuite { assert(Show[Nullable[Int]].show(nonEmpty) === Show[Int].show(1)) } - test("Functor overrides match default implementations") { - val optimized = optimizedTraverseForNullable - val defaults = defaultOnlyTraverse - - forAll { (fa: Nullable[Int], b: Int, f: Int => Long, fb: Nullable[Boolean], ifTrue: Int, ifFalse: Int) => - assert(optimized.map(fa)(f) === defaults.map(fa)(f)) - assert(optimized.as(fa, b) === defaults.as(fa, b)) - assert(optimized.void(fa) === defaults.void(fa)) - assert(optimized.tupleLeft(fa, b) === defaults.tupleLeft(fa, b)) - assert(optimized.tupleRight(fa, b) === defaults.tupleRight(fa, b)) - assert(optimized.fproduct(fa)(f) === defaults.fproduct(fa)(f)) - assert(optimized.fproductLeft(fa)(f) === defaults.fproductLeft(fa)(f)) - assert(optimized.ifF(fb)(ifTrue, ifFalse) === defaults.ifF(fb)(ifTrue, ifFalse)) - } - - forAll { (fab: Nullable[(Int, String)]) => - assert(optimized.unzip(fab) == defaults.unzip(fab)) - } - } - - test("Foldable overrides match default implementations") { - val optimized = optimizedTraverseForNullable - val defaults = defaultOnlyTraverse - val toLong: Int => Long = _.toLong + 1L - val sumLong: (Long, Int) => Long = (acc, i) => acc + i.toLong - val pfEven: PartialFunction[Int, Int] = { case i if i % 2 == 0 => i + 10 } - val firstSome: Int => Option[Int] = i => if i % 2 == 0 then Some(i + 1) else None - val traverseVoidF: Int => Option[Int] = i => if i % 2 == 0 then Some(i) else None - - forAll { (fa: Nullable[Int], idx: Long, target: Int, pred: Int => Boolean) => - assert(optimized.foldLeft(fa, 1)(_ + _) === defaults.foldLeft(fa, 1)(_ + _)) - assert( - optimized.foldRight(fa, Eval.now(1))((a, eb) => eb.map(_ + a)).value === - defaults.foldRight(fa, Eval.now(1))((a, eb) => eb.map(_ + a)).value - ) - assert(optimized.fold(fa) === defaults.fold(fa)) - assert(optimized.combineAllOption(fa) === defaults.combineAllOption(fa)) - assert(optimized.toIterable(fa).toList === defaults.toIterable(fa).toList) - assert(optimized.foldMap(fa)(toLong) === defaults.foldMap(fa)(toLong)) - assert(optimized.reduceLeftToOption(fa)(toLong)(sumLong) === defaults.reduceLeftToOption(fa)(toLong)(sumLong)) - assert( - optimized.reduceRightToOption(fa)(toLong)((i, e) => e.map(_ + i.toLong)).value === - defaults.reduceRightToOption(fa)(toLong)((i, e) => e.map(_ + i.toLong)).value - ) - assert(optimized.reduceLeftOption(fa)(_ + _) === defaults.reduceLeftOption(fa)(_ + _)) - assert( - optimized.reduceRightOption(fa)((i, e) => e.map(_ + i)).value === - defaults.reduceRightOption(fa)((i, e) => e.map(_ + i)).value - ) - assert(optimized.minimumOption(fa) === defaults.minimumOption(fa)) - assert(optimized.maximumOption(fa) === defaults.maximumOption(fa)) - assert(optimized.get(fa)(idx) === defaults.get(fa)(idx)) - assert(optimized.contains_(fa, target) === defaults.contains_(fa, target)) - assert(optimized.size(fa) === defaults.size(fa)) - assert(optimized.find(fa)(pred) === defaults.find(fa)(pred)) - assert(optimized.exists(fa)(pred) === defaults.exists(fa)(pred)) - assert(optimized.forall(fa)(pred) === defaults.forall(fa)(pred)) - assert(optimized.toList(fa) === defaults.toList(fa)) - assert(optimized.filter_(fa)(pred) === defaults.filter_(fa)(pred)) - assert(optimized.takeWhile_(fa)(pred) === defaults.takeWhile_(fa)(pred)) - assert(optimized.dropWhile_(fa)(pred) === defaults.dropWhile_(fa)(pred)) - assert(optimized.isEmpty(fa) === defaults.isEmpty(fa)) - assert(optimized.nonEmpty(fa) === defaults.nonEmpty(fa)) - assert(optimized.collectFirst(fa)(pfEven) === defaults.collectFirst(fa)(pfEven)) - assert(optimized.collectFirstSome(fa)(firstSome) === defaults.collectFirstSome(fa)(firstSome)) - assert(optimized.traverseVoid(fa)(traverseVoidF) === defaults.traverseVoid(fa)(traverseVoidF)) - } - } + test("Show[Nullable[Nullable[A]]] is consistent with Show[Nullable[A]]") { + val nestedEmpty: NestedNullable[Int] = Nullable.empty + val nestedNonEmpty: NestedNullable[Int] = Nullable(Nullable(1)) - test("Traverse overrides match default implementations") { - val optimized = optimizedTraverseForNullable - val defaults = defaultOnlyTraverse - - forAll { (fa: Nullable[Int], fga: Nullable[Option[Int]], init: Long, replacement: Int, idx: Long) => - val tapF: Int => Option[String] = i => if i % 2 == 0 then Some(i.toString) else None - val accumulateF: (Long, Int) => (Long, String) = (s, i) => (s + i.toLong, s"$s:$i") - val indexTraverseF: (Int, Int) => Option[String] = (i, n) => if (i + n) % 2 == 0 then Some(s"$i-$n") else None - val longIndexTraverseF: (Int, Long) => Option[String] = - (i, n) => if (i.toLong + n) % 2L == 0L then Some(s"$i-$n") else None - val indexMapF: (Int, Int) => String = (i, n) => s"$i@$n" - val longIndexMapF: (Int, Long) => String = (i, n) => s"$i@$n" - - assert(optimized.traverse(fa)(tapF) === defaults.traverse(fa)(tapF)) - assert(optimized.traverseTap(fa)(tapF) === defaults.traverseTap(fa)(tapF)) - assert(optimized.sequence(fga) === defaults.sequence(fga)) - assert(optimized.mapAccumulate(init, fa)(accumulateF) == defaults.mapAccumulate(init, fa)(accumulateF)) - assert(optimized.mapWithIndex(fa)(indexMapF) === defaults.mapWithIndex(fa)(indexMapF)) - assert(optimized.traverseWithIndexM(fa)(indexTraverseF) === defaults.traverseWithIndexM(fa)(indexTraverseF)) - assert(optimized.zipWithIndex(fa) === defaults.zipWithIndex(fa)) - assert( - optimized.traverseWithLongIndexM(fa)(longIndexTraverseF) === defaults.traverseWithLongIndexM(fa)( - longIndexTraverseF - ) - ) - assert(optimized.mapWithLongIndex(fa)(longIndexMapF) === defaults.mapWithLongIndex(fa)(longIndexMapF)) - assert(optimized.zipWithLongIndex(fa) === defaults.zipWithLongIndex(fa)) - assert(optimized.updated_(fa, idx, replacement) === defaults.updated_(fa, idx, replacement)) - } + assert(Show[NestedNullable[Int]].show(nestedEmpty) === java.lang.String.valueOf(null.asInstanceOf[AnyRef])) + assert(Show[NestedNullable[Int]].show(nestedNonEmpty) === Show[Nullable[Int]].show(Nullable(1))) } } From 18eb2822ad30f0a9566246311eb53f77de715c46 Mon Sep 17 00:00:00 2001 From: Oscar Boykin Date: Fri, 20 Feb 2026 09:14:22 -1000 Subject: [PATCH 6/6] add recursive nullable flatten --- .../src/main/scala-3/cats/data/Nullable.scala | 31 ++++++++++++++++--- .../scala-3/cats/tests/NullableSuite.scala | 11 +++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/core/src/main/scala-3/cats/data/Nullable.scala b/core/src/main/scala-3/cats/data/Nullable.scala index af3d049661..274cc5b8aa 100644 --- a/core/src/main/scala-3/cats/data/Nullable.scala +++ b/core/src/main/scala-3/cats/data/Nullable.scala @@ -28,6 +28,31 @@ import scala.quoted.* opaque type Nullable[+A] = A | Null object Nullable extends NullableInstances { + type Flattened[A] = A match { + case Nullable[x] => Nullable[x] + case _ => Nullable[A] + } + + private[data] sealed trait Flattening[A, B] { + def apply(value: Nullable[A]): B + } + + private[data] object Flattening extends Flattening0 { + given [A, B](using next: Flattening[A, B]): Flattening[Nullable[A], B] with { + def apply(value: Nullable[Nullable[A]]): B = { + next(value.asInstanceOf[Nullable[A]]) + } + } + } + + private[data] trait Flattening0 { + given [A]: Flattening[A, Nullable[A]] with { + def apply(value: Nullable[A]): Nullable[A] = { + value + } + } + } + // If we enable explicit nulls, strict equality needs this `CanEqual` for `== null` checks. // We keep it `private[data]` (not `private`) because explicit nulls are not enabled right now, // and a `private given` would otherwise trigger an unused-private-member warning. @@ -79,11 +104,9 @@ object Nullable extends NullableInstances { inline def iterator: Iterator[A] = { fold(Iterator.empty)(Iterator.single(_)) } - } - extension [A](inline nested: Nullable[Nullable[A]]) { - inline def flatten: Nullable[A] = { - nested + inline def flatten[B](using flattening: Flattening[A, B]): B = { + flattening(nullable) } } diff --git a/tests/shared/src/test/scala-3/cats/tests/NullableSuite.scala b/tests/shared/src/test/scala-3/cats/tests/NullableSuite.scala index 6289242571..5417cfa9d7 100644 --- a/tests/shared/src/test/scala-3/cats/tests/NullableSuite.scala +++ b/tests/shared/src/test/scala-3/cats/tests/NullableSuite.scala @@ -135,6 +135,17 @@ class NullableSuite extends CatsSuite { } } + test("flatten removes any number of nested Nullable wrappers down to one layer") { + val tripleEmpty: Nullable[Nullable[Nullable[Int]]] = Nullable(Nullable(Nullable.empty[Int])) + val tripleNonEmpty: Nullable[Nullable[Nullable[Int]]] = Nullable(Nullable(Nullable(1))) + + val flattenedEmpty: Nullable[Int] = tripleEmpty.flatten + val flattenedNonEmpty: Nullable[Int] = tripleNonEmpty.flatten + + assert(flattenedEmpty === Nullable.empty[Int]) + assert(flattenedNonEmpty === Nullable(1)) + } + test("minimal Functor[Nullable] fails composition when composed with itself") { val candidate = new Functor[Nullable] { def map[A, B](fa: Nullable[A])(f: A => B): Nullable[B] =