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

Produire du code qui fonctionne du premier coup est une sensation incroyable. Au-delà de l’accomplissement personnel, cela réduit aussi considérablement le temps de développement. En effet, lorsque le projet prend de l’ampleur, il n’est pas toujours facile de remarquer les erreurs d’étourderies et ces dernières se manifestent (normalement) dans l’environnement de test.

Le problème ? Le temps pour que le code arrive en test est incroyablement long, il faut que la CI lance tous les checks, que la PR soit validée, puis qu’elle soit déployée, et ensuite que le bug soit détecté manuellement. Et même si on se dit qu’avec tous ces filtres, l’étourderie devrait disparaitre, en réalité, il m’est déjà arrivé un nombre incalculable de fois que cette dernière persiste jusqu’en production.

Le typage fort à la rescousse !

C’est pour cette raison que j’estime qu’un typage fort est un véritable plus. Il est peut-être embêtant sur le court terme (pour des POCs par exemple), mais sur le long terme, il permet une très grande stabilité.

On a de la chance, avec Scala c’est exactement ce qu’on a ! Ce genre d’ânerie n’est pas possible puisque le compilateur ne voudra pas laisser passer votre code :

val id: String = 5

Comme toute bonne chose, on en veut toujours plus ! Les Strings, les Ints, tout cela reste très générique dans l’idée.

Cela soulève deux problèmes :

  • 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).

Aujourd’hui, je vous propose de répondre au premier problème et de voir les solutions que nous avons à notre disposition pour le résoudre.

Partons d’un modèle de donnée qui regroupe des utilisateurs repartis dans des projets. On peut créer le domaine de la manière suivante :

case class User (
	id: String
)

case class Team (
	id: String,
	users: List[User]
)

Jusque-là, il n’y a aucun souci particulier. Mais, imaginons maintenant que vous avez la fonction suivante :

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

Pouvez-vous me dire, en étant 100% sûr de vous, à quoi fait référence id ?

Utiliser un alias de type

Un moyen simple pour régler ce quiproquo est d’utiliser les alias de type. Voici comment faire avec notre exemple :

case class User (
	id: UserId
)

type UserId = String

case class Team (
	id: TeamId,
	users: List[User]
)

type TeamId = String

Il faut bien comprendre que UserId et TeamId sont des alias de String et peuvent être utilisés à la place de String en affectant uniquement la lisibilité du code.

Et voilà la nouvelle fonction associée :

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

On y voit tout de suite plus clair ! L’utilisation des alias de type rentre dans le cadre du “types as documentation”. Ici, la signature de la fonction se suffit à elle-même, nous n’avons ni besoin de lire le contenu de la fonction pour comprendre ce qu’elle fait, ni besoin de rédiger une docstring pour l’expliquer.

Malheureusement, ce n’est pas aussi simple et il y a un hic…

val user = User("uuid")

val hours = getWorkingHoursLastWeek(user.id) // It compiles 💀

Les alias de types aiguillent les programmeurs, mais pas le compilateur. Pour lui, ce n’est que des simples String sous le capot. Il va falloir changer de stratégie.

Utiliser une case class

Reprenons l’exemple précédent, mais au lieu d’utiliser un alias de type, nous allons utiliser une case class pour représenter notre id :

case class User (
	id: UserId
)

case class UserId(value: String)

case class Team (
	id: TeamId,
	users: List[User]
)

case class TeamId(value: String)

Si on reprend là où on s’était arrêté :

val user = User(UserId("uuid"))

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

C’est plutôt pas mal ! Notre fonction nous indiquera immédiatement si l’id qu’on lui renseigne est bien un id de team ou pas. Cela rajoute un peu d’overhead puisqu’il faut wrapper l’id dans une case class. Mais en réalité, hormis dans les tests, on ne le fait quasiment jamais. Généralement, l’utilisateur sera créé suite à un processus de désérialisation.

À ce sujet, je vous conseille de vous-même définir des Encoders et des Decoders qui imite le comportement d’un String dans ce cas précis, voici un exemple avec zio-json :

case class UserId(value: String)

object UserId {
	implicit val codec:JsonCodec[UserId] = JsonCodec.string.transform(UserId.apply, _.value)
}

En utilisant ce codec, le JSON ressemblera à ceci :

{"id": "uuid"}

Alors que si on utilise l’auto-dérivation, le JSON ressemblera à :

{"id": {"value": "uuid"}}   

Cela alourdit le format pour rien puisque value ne donne aucune information supplémentaire.

Wrapper l’id a aussi un autre avantage, vous ne pouvez plus utiliser les opérations de String sur un UserId. Concaténer des ids n’a, par exemple, aucun sens. Vous pouvez donc définir vos propres opérations et créer une véritable API autour de votre type.

Mais il y a encore un hic… Cette façon de faire va créer un overhead qui, sur le long terme, peut impacter les performances de vos applications. Il va falloir peaufiner notre stratégie.

Utiliser une value class

Il existe une manière pour enlever cette overhead au runtime : les value class (depuis Scala 2.10). Si vous voulez plus d’informations, vous pouvez vous rendre dans la documentation (liée au SIP-15) sur le site du langage Scala. C’est assez simple à mettre en place en reprenant l’exemple précédent :

case class User (
	id: UserId
)

case class UserId(value: String) extends AnyVal

case class Team (
	id: TeamId,
	users: List[User]
)

case class TeamId(value: String) extends AnyVal

Pour vous, rien ne change. Mais au runtime tous les UserId et TeamId seront considérés comme des Strings. On élimine complétement l’overhead !

En fait, non ! Il y a encore un hic… J’ai menti, l’overhead n’est pas complétement éliminé. Je vous laisse lire la partie motivation des types opaques (que nous allons voir ci-dessous) dans la documentation du SIP-35 pour en apprendre plus à ce sujet.

Utiliser un opaque type (Scala 3 only)

Comme dit précédemment, une value class n’élimine pas complétement l’overhead. Scala 3 introduit en conséquence une nouvelle feature qui est censé régler ce problème, à savoir les opaques types. Reprenons donc notre exemple :

case class User (
	id: UserId
)

opaque type UserId = String

object UserId {
	def apply(value: String): UserId = value
}

case class Team (
	id: TeamId,
	users: List[User]
)

opaque type TeamId = String

object TeamId {
	def apply(value: String): TeamId = value
}

Cette manière de faire ne comporte plus aucun overhead et répond 100% à notre problématique de départ.

Mais il y a encore un hic… Pour l’avoir utilisé à titre personnel, ces derniers ne sont pas encore bien supportés par les différentes librairies qui composent l’écosystème Scala. J’ai eu notamment des problèmes avec Tapir et ZIO quillnotamment.

Conclusion

Pour conclure, il y a eu un énorme chemin de parcouru pour arriver à avoir des sous types safes et sans overhead. Avoir des sous types qui précisent les différentes valeurs que nous manipulons permet d’avoir un code encore plus clair et précis qu’avec les types de base. C’est selon moi un vrai plus dans une codebase et je n’ai, pour l’instant, vu aucun point négatif à les utiliser. Les opaques types semblent extrêmement prometteurs dans ce sens-là. Mais du fait du manque de compatibilité actuel, je ne vous les conseille pas en production pour autant. De manière plus étendue, comme Scala 3 reste assez jeune (cette version est sortie l’année dernière), je considère qu’il vaut mieux attendre avant de l’envoyer en production. Particulièrement avec l’effort fourni par VirtusLab qui propose gratuitement de migrer les projets Scala 2 vers 3, quasiment tout l’écosystème est d’ores et déjà fonctionnel en Scala 3 ! Selon moi, ce sont surtout les IDEs qui peinent pour le moment à s’adapter.

À titre personnel, je me suis résigné à repasser aux value classes pour le moment.

Dans cet article, nous avons vu les solutions que proposait le langage Scala. Dans le prochain article, nous passerons aux différentes librairies qui composent cet écosystème et qui nous permet d’être encore plus précis et safe.