Comment augmenter la sûreté de nos types en Scala (partie 2)

Nous avons vu dans la partie 1 comment rendre nos types plus forts en utilisant seulement les outils que nous donne le langage sans utiliser aucune librairie. Dans cette partie, nous allons voir les réponses de la communauté pour répondre aux deux problématiques que j’avais établi dans le premier article, à savoir :

  • Deux valeurs du même type peuvent être interchangeables (un paramètre String peut recevoir à la fois un mail et un nom d’utilisateur sans provoquer une erreur).
  • Une valeur peut avoir un domaine plus restreint que ce que permet un type (un email est un String, mais tous les Strings ne sont pas des emails).

La première problématique

Nous avons déjà longuement répondu à cette dernière et la réponse fournie par le langage semble avoir aussi convaincu toute la communauté puisque, à ma connaissance, il n’y a pas eu beaucoup de travaux open source pour répondre à cette problématique.

La seule véritable proposition atypique que j’ai croisée est celle de Miles Sabin qui a été ensuite implémentée par softwaremill. Elle repose sur la tagging pour différencier deux mêmes types. Si on reprend l’exemple de la partie 1 :

import com.softwaremill.tagging._

case class User {
	id: String @@ UserId
}

trait UserId

case class Team {
	id: String @@ TeamId,
	users: List[User]
}

trait TeamId

On peut résoudre correctement l’exemple ci-contre :

val user = User("uuid".taggedWith[UserId])

def getWorkingHoursLastWeek(id: String @@ TeamId): Int = ???

val hours = getWorkingHoursLastWeek(user.id) // It does not compile 🔥

Ça fonctionne ! Cette méthode a aussi l’avantage d’être totalement transparente au runtime (tout comme les types opaques et dans une moindre mesure les value classes). Il a aussi un autre avantage comparé à ce qu’on a déjà vu : on peut automatiquement récupérer n’importe quelle typeclass du type taggé avec quasiment aucun overhead.

Il suffit d’utiliser l’import suivant :

import com.softwaremill.tagging.AnyTypeclassTaggingCompat._

La deuxième problématique

Passons maintenant à la deuxième problématique que j’ai soulevée. Afin de comprendre en quoi cela en est une, nous allons pour cela prendre le cas d’une fonction assez connue :

def take[A](list: List[A], n: Int): List[A] = ??? 

Cette dernière prend les n premiers éléments d’une List. Vous êtes-vous déjà demandé ce qui arriverait si on renseignait un n négatif ?

Il existe une infinité de possibilités, les plus courantes sont les suivantes :

  • La fonction renvoie une exception
  • La fonction gère les valeurs incohérentes de n en les remplaçant par zéro
  • La fonction renvoie la List à partir du dernier élément (coucou python)

On pourrait discuter des heures sur laquelle de ces possibilités est la meilleure. En réalité, le problème ici est que la fonction devrait ne pouvoir prendre que des nombres supérieurs à zéro.

Malheureusement, le langage ne nous fournit pas les moyens pour régler ce genre de problème directement au compile time. Fort heureusement, des librairies permettent de pallier ce besoin.

Refined

La plus connue est très certainement refined. Si on reprend l’exemple précédent :

import eu.timepit.refined.api.Refined
import eu.timepit.refined.auto._
import eu.timepit.refined.numeric._

def take[A](list: List[A], n: Int Refined Positive): List[A] = ??? 

Grâce à refined, on peut spécialiser nos types. Ici, on précise que n, en plus de devoir être un int, doit aussi être un nombre positif (cela n’a pas de sens de demander un nombre négatif comme dit au-dessus, mais 0 n’a aussi pas vraiment de sens). Refined fournis un grand nombre de modificateurs que vous pouvez combiner avec un grand nombre de types. À titre d’exemple, vous pouvez :

  • Forcer des Strings à être des URLs
  • Forcer des Strings à ne pas être vide
  • Forcer des Strings à être des adresses mails
  • Forcer des Floats à être entre 0 et 1

Bien évidemment, la librairie vous donne aussi la possibilité de créer vos propres modificateurs, je vous invite à lire la documentation pour plus d’informations.

Il faut cependant différencier les valeurs reçues au compile time de celles reçues au runtime. Cette librairie ne peut pas savoir à l’avance que la valeur rentrée par un utilisateur correspond au prédicat. Pour pallier ce problème, une fonction vous permet de récupérer la valeur ou une erreur dans le cas où le prédicat n’est pas respecté au runtime :

val userPositiveInt: Either[String, Int Refined Positive] = refineV[Positive](???)

NewType

Refined répond parfaitement à la problématique numéro 2. Malheureusement, cela ne répond pas à la problématique numéro 1 et il nous faudra alors combiner deux méthodes pour obtenir quelque chose de concluant.

Pour être honnête, ce genre de syntaxe est assez lourde :

case class User {
	id: String Refined NonEmpty @@ UserId
}

Ce serait génial si une librairie pouvait faire les deux en même temps !

Fort heureusement pour nous, il existe une solution : les NewTypes de zio-prelude. Prenons le cas de notre User :

import zio.prelude.Assertion.isEmptyString
import zio.prelude.Subtype

case class User(id: UserId)

object UserId extends Subtype[String] {
  override def assertion = assert { !isEmptyString }
}

type UserId = UserId.Type

La réponse donnée par les NewTypes répond bien aux deux problématiques puisque :

  • UserId est un type bien définit qui ne peut pas être remplacé par un String, ce qui permet d’empêcher les erreurs d’étourderies.
  • UserId a une “assertion” qui permet au compile time de valider que la donnée renseignée est celle attendue

Tout comme pour refined, vous pouvez aussi saisir des valeurs au runtime de la manière suivante :

val userPositiveInt: Validation[String, UserId] = UserId.make(???)

Conclusion

Il existe beaucoup de possibilités dans l’écosystème Scala pour avoir des types beaucoup plus précis, au compile time et sans overhead. Selon moi, chacun à son utilité, ses avantages et ses inconvénients.

Si vous voulez mon avis, le choix devrait dépendre du contexte :

  • Refined offre une syntaxe très élégante lorsqu’il s’agit de traiter des cas génériques, mais qui ne permet pas de différencier les types métiers.
  • Les NewTypes de zio-prelude quant à eux offrent une syntaxe plus encombrante, mais gère correctement le besoin d’avoir des types ayant les mêmes prédicats, mais pas la même signification d’un point de vue métier.
  • Les opaque types sont directement intégrés au langage et donc censés mieux résister à l’épreuve du temps, mais ils ne prennent pas en compte l’aspect validation au compile time.