Testing presence of type in union

Recently, I thought about how I encode nullness in my database library, and if I should change it. Currently I use Option to encode null, and ensure Option is never nested by using this type.

sealed trait NullableHelperTrait[A]
type Nullable[A] <: Option[?] = NullableHelperTrait[A] match {
  case NullableHelperTrait[Option[b]] => Option[b]
  case _                              => Option[A]
}

The sealed trait is there so the match type works fine with opaque types, which is what made me initially reconsider my encoding of null.

In an ideal world, I would use a union type instead. Something like this

case object SqlNull
type SqlNull = SqlNull.type
type Nullable[A] = A | SqlNull

It naturally prevents repeated applications of null, as A | SqlNull =:= A | SqlNull | SqlNull.

The problem is that from what I have found, there is no way to model the virality of nullness in sql. I need a way to determine if a type contains SqlNull. In the current Option implementation this is done with typeclasses, but a match type would be just as fine with union types. Sadly, the obvious (and non obvious) solutions do not work.

type IsNull[A] = A match {
  case SqlNull => true
  case _       => false
}

sealed trait IsNotNullTrait[-A]
type IsNotNull[A] = IsNotNullTrait[A] match {
  case IsNotNullTrait[SqlNull] => false
  case _ => true
}

Making a type that matches one case seems entirely possible, but I have not found a way to get both cases.

Have I missed something, or is this simply not possible with union types as they exist currently? If not, why not? Could it be fixed in any way?

I’ve also stumbled upon this many times when working with nulls or other types. Seems like quite an intuitive way of attacking the problem that scalac just doesn’t like, defeating the principle of least surprise. In my case it was doing a play json Format for A | B types (where it would try them one at a time), as well as some similar api similar to .nn to work with nullable types.

I understand why scala, with its subtyping rules, can’t really extricate a subset from a union type (or I think I understand, at least), but it is quite unintuitive, plus typescript does have functionality to work and compute types based on unions.

In the current Option implementation this is done with typeclasses, but a match type would be just as fine with union types.

Maybe I’m missing something, but couldn’t you keep using a case class like

sealed trait IsNullable[A]
object IsNullable {
  def apply[A](implicit in: IsNullable[A]): IsNullable[A] = in
  implicit def optionIsNullable[A]: IsNullable[Option[A]] = new IsNullable[Option[A]] {}
  implicit def unionIsNullable[A >: SqlNull]: IsNullable[A] = new IsNullable[A] {}
}

Scastie example: Scastie - An interactive playground for Scala.

The typeclass you show only tells me if a type is nullable. If it isn’t nullable, the implicit search fails.

Currently the typeclass I use looks something like this (simplified).

trait NullabilityA]:
  type NNA
  type N[_]
object Nullability:
  type Aux[A, NNA0, N0[_]] = Nullability[A] { type N[B] = N0[B]; type NNA = NNA0 }

  given notNull[A](using NotGiven[A <:< Option[?]]): Nullability.Aux[A, A, Id] = new Nullability[A]:
    type N[B] = B
    type NNA  = A

  given nullable[A <: Option[NN], NN]: Nullability.Aux[A, NN, Option] = new Nullability[A]:
    type N[B] = Option[B]
    type NNA  = NN

From my testing, getting Scala to pick the right given when using unions didn’t work well.
This is the definition I used.

  given nullable[NN, A >: SqlNull | NN](using NotGiven[SqlNull <:< NN]): Nullability.Aux[A, NN, Nullable] = new Nullability[A]:
    type N[B] = B | SqlNull
    type NNA = NN