1. Introduction
Quand on debute en programmation, les exceptions sont souvent perçues comme un eclair : « quelque chose a frappe quelque part, et le programme s’est ecrase ». Mais a mesure que le projet grandit, on se rend compte que certaines erreurs sont en fait des situations normales de la vie. L’utilisateur a saisi autre chose qu’un nombre, a demande de supprimer un enregistrement qui n’existe pas, a indique une categorie vide. Ce n’est pas « la fin du monde », c’est simplement « pas de resultat » — mais avec une explication.
Et c’est la qu’apparait le dilemme classique : si vous lancez des exceptions partout, le code de l’utilisateur de votre fonction se transforme en foret de try/catch. Si vous renvoyez null partout, on perd vite le sens : pourquoi null ? « Introuvable » ? « Saisie invalide » ? « Logique casse » ? Kotlin, meme dans les exemples de la bibliotheque standard, montre que parfois il vaut mieux utiliser des alternatives sures comme toIntOrNull() au lieu de provoquer un crash avec toInt() — justement parce que « pas un nombre » est souvent attendu.
Result<T> est l’un des outils qui aide a dire : « La fonction peut se terminer par un succes ou une erreur, et je vais renvoyer cela comme une valeur, pas comme une explosion ».
2. Qu’est-ce que Result<T> : une valeur ou une erreur dans une seule boite
Imaginez une boite avec une etiquette « Attention » : a l’interieur, soit un objet utile (par exemple, Int), soit un papier expliquant pourquoi l’objet n’a pas pu etre obtenu (sous forme de Throwable). C’est ca Result<T> : un conteneur qui stocke l’un des deux resultats d’execution — un succes avec T ou une erreur avec une exception.
Idee importante : Result<T> n’est pas de la magie et ne remplace pas toutes les approches. C’est simplement une forme de transmission du resultat, qui permet de ne pas obliger chaque utilisateur de la fonction a ecrire try/catch, si la situation est attendue. Mais en meme temps, « l’erreur » reste une exception complete (type + message + cause potentielle), pas une chaine et pas null. C’est pratique, car les exceptions en Kotlin ont un riche ecosysteme de types (NumberFormatException, IllegalArgumentException, etc.).
Schema possible :
flowchart TD
A[La fonction fait quelque chose] --> B{Ca a marche ?}
B -->|oui| C["Result.success(value)"]
B -->|non| D["Result.failure(exception)"]
C --> E[Le code appelant decide : prendre la valeur, utiliser un fallback ou s’arreter]
D --> E
3. Comment creer un Result : success(...) et failure(...)
Creer un Result est tres simple. Il existe deux manieres fabrique de base :
- Result.success(value) — on emballe la valeur.
- Result.failure(exception) — on emballe l’erreur.
Commençons par un micro-exemple qui n’est pas lie au projet — juste pour voir la mecanique, sans se laisser distraire par la logique metier.
fun main() {
val ok: Result<Int> = Result.success(10)
val bad: Result<Int> = Result.failure(IllegalArgumentException("nope"))
println(ok) // Success(10)
println(bad) // Failure(java.lang.IllegalArgumentException: nope)
}
Ce n’est pas encore une « belle application », mais on voit deja : la variable contient un resultat, pas « ca a explose / ca n’a pas explose ».
Maintenant, une nuance pratique importante : plus l’exception dans failure est de qualite, plus il sera facile ensuite de comprendre ce qui s’est passe.
4. Renvoyer un Result depuis des fonctions : exemple de projet
Pour que les exemples ne soient pas « dans le vide », continuons notre projet console pratique (appelons-le, par convention, BudgetBuddy) : il stocke des depenses et sait accepter les commandes add, list, remove. Nous ne touchons ni aux fichiers ni au reseau — ce sera pour d’autres jours du cours — donc le stockage sera simplement en memoire.
Dans le domaine, nous avons un modele de depense :
data class Expense(
val id: Int,
val amount: Int,
val category: String,
val note: String
)
Maintenant, imaginons une tache typique : l’utilisateur saisit un montant sous forme de texte, et nous voulons le parser en Int. Si on l’ecrit « en dur » via toInt(), alors en cas de saisie incorrecte on obtiendra NumberFormatException.
Dans les cours precedents, nous avons deja vu une variante de parsing « doux » via toIntOrNull(). Maintenant, faisons une version avec un contrat explicite : renvoyons Result<Int>, ou, en cas d’erreur, il y aura une exception avec un message clair.
fun parseAmount(text: String): Result<Int> {
val trimmed = text.trim()
val n = trimmed.toIntOrNull()
?: return Result.failure(NumberFormatException("Amount is not a number: '$text'"))
if (n <= 0) {
return Result.failure(IllegalArgumentException("Amount must be > 0, got $n"))
}
return Result.success(n)
}
Notez le style : nous ne lançons pas l’exception vers l’exterieur, nous l’emballons comme une valeur. Et encore un point : nous avons ajoute got ... — cela accelere le diagnostic, car le message montre la valeur effective.
5. Result comme contrat : la fonction ne decide pas du destin de l’erreur
Il est tres facile de mal comprendre Result : comme s’il « gerait l’erreur ». Il ne la gere pas. Il dit simplement proprement : « Voici le resultat, a toi de decider ensuite ».
Pour le sentir, ecrivons une fonction qui cree une depense, mais renvoie Result<Expense>. A l’interieur, elle utilise parseAmount.
fun createExpense(id: Int, amountText: String, category: String, note: String): Result<Expense> {
val amountResult = parseAmount(amountText)
val amount = amountResult.getOrElse { return Result.failure(it) }
val cat = category.trim()
if (cat.isEmpty()) {
return Result.failure(IllegalArgumentException("Category must not be blank"))
}
return Result.success(Expense(id = id, amount = amount, category = cat, note = note.trim()))
}
Ici, un point important s’est produit : nous avons applique getOrElse comme moyen d’extraire la valeur ou de sortir en cas d’erreur. C’est proche du style guard clauses : des qu’on voit qu’on ne peut pas continuer correctement — on renvoie proprement une erreur, sans construire un enorme if sur toute la methode.
Et oui, on aurait pu faire cela via une exception, mais alors l’appelant aurait ete oblige d’ecrire try/catch (ou le programme ferait « boum » — et c’est tout). Or notre objectif est de rendre le contrat lisible directement dans le type : Result<Expense>.
6. Travailler avec Result en CLI : extraction, branchement et pipelines
Quand Result arrive dans le code appelant, on a un choix : extraire la valeur, fournir une valeur de repli, afficher un message et terminer le scenario, ou bien « re-exploser » l’erreur (si, a ce niveau, elle est deja consideree comme critique).
Ici, il est important de ne pas agir selon le principe « bon, je vais mettre 0 partout ». Parfois, 0 est une excellente decision, et parfois c’est une erreur cachee qui se transforme ensuite en statistiques cassees et en rapports mysterieux.
Ci-dessous — un tableau des methodes principales que nous utilisons aujourd’hui :
| Methode | Ce que ca renvoie | Quand c’est pertinent | Risque typique |
|---|---|---|---|
|
|
quand l’erreur « n’est pas importante » et qu’on peut vivre avec null | perdre la cause de l’erreur |
|
|
quand on veut recuperer l’erreur separement et l’afficher / la logger | oublier de gerer le succes |
|
|
quand on a un fallback pertinent ou qu’on veut sortir de la fonction | faire un « pansement universel » |
|
ou lancera une exception |
quand, a ce niveau, l’erreur est deja critique | transformer Result en wrapper inutile |
Voyons maintenant cela sur de courts exemples.
getOrNull() — transformer Result<T> en T?
Cette chose est utile quand on veut consciemment un « comportement doux » : soit un nombre, soit null. En Kotlin, ce style est deja familier avec toIntOrNull() et avec les methodes de collections finissant par OrNull.
fun main() {
val n: Int? = parseAmount("123").getOrNull()
println(n) // 123
val bad: Int? = parseAmount("x").getOrNull()
println(bad) // null
}
Le defaut est evident : la cause de la perte de valeur disparait. Donc getOrNull() est un choix conscient : « la raison ne m’importe pas ».
exceptionOrNull() — recuperer l’exception comme des donnees
Si nous voulons expliquer a l’utilisateur pourquoi la commande n’a pas abouti, on veut generalement obtenir le texte de l’erreur.
fun main() {
val r = parseAmount("x")
val msg = r.exceptionOrNull()?.message ?: "OK"
println(msg) // Amount is not a number: 'x'
}
Notez : exceptionOrNull() renvoie Throwable?, donc les appels surs ?. s’imposent ici.
getOrElse { ... } — un fallback pertinent ou une sortie anticipee
getOrElse est bon quand vous savez vraiment quoi faire en cas d’erreur. Par exemple, si l’utilisateur n’a pas saisi de note, on peut substituer « (no note) ». Mais avec l’argent, faire ca peut etre dangereux.
Montrons une variante ou le fallback est raisonnable :
fun normalizedNote(note: String): Result<String> =
if (note.isBlank()) Result.failure(IllegalArgumentException("Empty note"))
else Result.success(note.trim())
fun main() {
val note = normalizedNote(" ").getOrElse { "(no note)" }
println(note) // (no note)
}
Ici, le fallback fait partie de l’UX, pas du « on cache le probleme sous le tapis ».
getOrThrow() — revenir au monde des exceptions
Parfois, Result est necessaire au niveau bas pour collecter proprement l’erreur, mais au niveau haut on decide : « Si ici il y a une erreur, c’est stop, on ne peut pas aller plus loin ». Alors getOrThrow() lancera honnetement l’exception vers l’exterieur.
fun main() {
val amount = parseAmount("x").getOrThrow()
println(amount) // on n’arrivera pas ici
}
Pourquoi ce n’est pas inutile ? Parce que la politique peut etre differente selon les couches. La couche basse peut « emballer » les erreurs, et la couche haute decide qu’a cet endroit le scenario doit s’arreter.
onSuccess {} et onFailure {} : brancher sans try/catch
Quand vous ecrivez une interface console, vous devez souvent faire une chose simple : si succes — on affiche OK, si erreur — on affiche ERROR: …. Et c’est ici que onSuccess / onFailure permettent de ne pas transformer le tout en chaines de if.
Faisons une petite fonction qui affiche le resultat d’execution d’une commande :
fun printCommandResult(result: Result<String>) {
result
.onSuccess { println("OK: $it") }
.onFailure { println("ERROR: ${it.message}") }
}
Et un exemple d’utilisation :
fun main() {
val ok = Result.success("Expense added")
val bad = Result.failure(IllegalArgumentException("Wrong command"))
printCommandResult(ok) // OK: Expense added
printCommandResult(bad) // ERROR: Wrong command
}
Ce n’est pas encore une « application complete », mais on voit deja : le branchement est devenu propre et local. Nous ne sommes pas obliges de catcher l’exception — elle est deja « dans la boite », et nous decidons simplement comment l’afficher.
Mini-pipeline de la commande add dans main
Assembleons un petit morceau qui ressemble a une vraie saisie de commandes. Je rappelle que dans les jours precedents nous avons deja fait l’analyse de la chaine, la normalisation, split, les commandes, etc. Ici, nous montrons simplement comment Result aide a ne pas transformer main en un enorme try/catch.
Commande :
add <amount> <category> <note...>
Par exemple :
add 120 food Coffee
fun handleAddCommand(input: String, nextId: Int): Result<Expense> {
val parts = input.trim().split(" ")
if (parts.size < 4) {
return Result.failure(IllegalArgumentException("Usage: add <amount> <category> <note...>"))
}
val amountText = parts[1]
val category = parts[2]
val note = parts.drop(3).joinToString(" ")
return createExpense(id = nextId, amountText = amountText, category = category, note = note)
}
Et voici a quoi cela peut ressembler dans main :
fun main() {
val input = "add x food Coffee"
val result = handleAddCommand(input, nextId = 1)
result
.onSuccess { println("Added: $it") }
.onFailure { println("Cannot add: ${it.message}") }
// Cannot add: Amount is not a number: 'x'
}
Ici, il est important que l’erreur « pas un nombre » soit une NumberFormatException, un cas typique d’echec de format. Mais nous ne laissons pas cela « faire exploser » le programme. Nous la transformons en un resultat gerable.
Comment ne pas transformer Result en « cache-erreurs »
Result est facile a mal utiliser : emballer une erreur, marmonner quelque chose dans la console et continuer comme si tout allait bien. Au final, le programme devient tres « resilient »… mais commence a mentir. C’est presque comme coller du scotch sur le voyant « Check engine » : c’est plus silencieux, mais le probleme n’a pas disparu.
Pour eviter ca, il est utile de garder en tete la regle : apres une erreur, il doit toujours y avoir une decision. Dans un programme CLI, les decisions sont generalement au nombre de trois : afficher un message et continuer la boucle, afficher un message et terminer le programme, ou fournir un fallback pertinent. Si vous avez fait onFailure { } et que vous n’avez rien fait a l’interieur — c’est comme si vous aviez dit : « J’ai vu un incendie et j’ai decide de laisser bruler ; je ne regarde juste pas ».
Mini-exemple de « bonne discipline » — transformer Result en une chaine que la CLI affichera ensuite :
fun toCliMessage(result: Result<Expense>): String {
val e = result.exceptionOrNull()
return if (e != null) {
"ERROR: ${e.message}"
} else {
val expense = result.getOrThrow()
"OK: added id=${expense.id}, amount=${expense.amount}"
}
}
Il y a une subtilite : nous avons d’abord verifie l’erreur, puis seulement fait getOrThrow() — autrement dit, nous « exploserons » uniquement si nous avons viole notre propre logique (si, pour une raison quelconque, l’erreur est null et qu’il n’y a pas de valeur — ce qui n’arrive normalement pas avec un Result correct). Ce style ressemble au fail-fast : il faut tomber la ou la logique du programme s’est brisee, pas la ou l’utilisateur a simplement saisi x au lieu d’un nombre.
7. Erreurs typiques lors de l’utilisation de Result<T>
Erreur n° 1 : transformer Result en null via getOrNull() et oublier qu’il y avait une erreur.
Cela arrive souvent quand on veut « que ca marche, point ». Au final, vous perdez la raison et vous vous privez de la possibilite d’expliquer correctement a l’utilisateur ce qui ne va pas. Si, cote UX, le « pourquoi » est important, mieux vaut recuperer exceptionOrNull() et construire un message, plutot que de recevoir silencieusement null.
Erreur n° 2 : mettre getOrElse { 0 } comme pansement universel.
Pour un montant de depense, 0 peut paraitre innocent, mais cela peut masquer un vrai probleme : par exemple, l’utilisateur a saisi n’importe quoi, et vous avez silencieusement enregistre une depense a 0. Ensuite, les rapports « pour une raison quelconque » ne tombent plus juste. Le fallback doit etre pertinent dans le contexte de la tache, pas simplement pratique pour le compilateur.
Erreur n° 3 : ecrire Result.failure(Exception("oops")) sans sens et sans donnees factuelles.
Le message oops peut etre drole pendant exactement 3 secondes, jusqu’a ce que vous commenciez a debugger. Un message correct doit dire ce qui etait attendu et ce qui a ete obtenu, y compris la valeur effective (got ...).
Erreur n° 4 : utiliser onFailure { } comme « silencieux » et continuer comme si de rien n’etait.
Result n’est pas oblige de « faire tomber » le programme — et c’est son avantage. Mais si, en cas d’erreur, vous n’avez pas choisi une politique (s’arreter, continuer, fallback), alors vous n’avez pas traite l’erreur : vous l’avez cachee. A ce moment, les bugs commencent a voyager dans le code en silence, et ils vous retrouvent le jour le plus inconvenient.
Erreur n° 5 : renvoyer Result depuis une fonction, puis, au tout premier point d’appel, faire getOrThrow() partout et tout le temps.
Ainsi, vous transformez Result en surcouche inutile : formellement, vous avez « deplace » l’exception dans une valeur, puis vous avez tout de suite renvoye l’exception au dehors sans gain de lisibilite. Si vous avez choisi le contrat Result, donnez au code appelant la possibilite de gerer consciemment l’issue via onSuccess/onFailure, getOrElse, ou une transformation en message comprehensible.
GO TO FULL VERSION