Public cible : Développeurs Java intermédiaires/avancés Prérequis : Java 11+, Maven, notions de Spring Boot
Imaginez ce scénario courant dans une application Java traditionnelle :
// Code Java "classique" — plein de pièges ! public User findUserById(Long id) { return userRepository.findById(id); // Peut retourner null ! } public String getCity(Long userId) { User user = findUserById(userId); // Peut être null if (user == null) return "Inconnu"; Address address = user.getAddress(); // Peut être null if (address == null) return "Inconnu"; City city = address.getCity(); // Peut être null if (city == null) return "Inconnu"; return city.getName(); }
Ce code souffre de plusieurs problèmes :
Il est verbeux : beaucoup de vérifications null Il est fragile : oublier un null check = NullPointerException en production Il cache l’intention : le code métier est noyé dans la gestion d’erreurs Il est difficile à composer : on ne peut pas chaîner facilement les opérations
null
null check
NullPointerException
VAVR (anciennement Javaslang) est une bibliothèque Java qui apporte les concepts de la programmation fonctionnelle directement dans vos projets Java.
Pensez à VAVR comme un “kit de mise à niveau fonctionnel” pour Java. Il s’inspire de langages comme Scala et Haskell.
Option<T>
try/catch
Try<T>
Either<L, R>
Validation<E, T>
Ajoutez la dépendance Maven dans votre pom.xml :
pom.xml
<dependency> <groupId>io.vavr</groupId> <artifactId>vavr</artifactId> <version>0.10.4</version> </dependency>
Note : VAVR 0.10.x est compatible Java 8+. Pour Java 21+, cette version fonctionne toujours très bien.
Avant de plonger dans VAVR, il faut comprendre les concepts sur lesquels il repose.
Une fonction pure est une fonction qui :
// Fonction pure — prévisible, testable public int additionner(int a, int b) { return a + b; } // Fonction impure — effet de bord (log), résultat potentiellement différent public int additionnerAvecLog(int a, int b) { System.out.println("Calcul en cours..."); // Effet de bord ! return a + b; }
Pourquoi c’est important ? Les fonctions pures sont facilement testables, composables, et parallélisables.
Un objet immuable ne peut pas être modifié après sa création. En Java, String est immuable. VAVR étend ce concept à toutes ses structures de données.
String
// Objet mutable — DANGEREUX dans un contexte concurrent List<String> listeMutable = new ArrayList<>(); listeMutable.add("a"); // Modifie l'état ! // Objet immuable VAVR — SÛRE io.vavr.collection.List<String> listeImmuable = List.of("a", "b", "c"); io.vavr.collection.List<String> nouvelleListe = listeImmuable.prepend("z"); // listeImmuable est INCHANGÉE, nouvelleListe est une nouvelle liste
C’est un concept fondamental pour comprendre Either et Option.
Either
Option
Un type somme (ou sum type) représente une valeur parmi plusieurs possibilités mutuellement exclusives.
Option<T> = Some(T) | None Either<L,R> = Left(L) | Right(R) Try<T> = Success(T) | Failure(Exception)
Analogie avec le tennis de table : Lors d’un échange, soit la balle passe (succès), soit elle ne passe pas (échec). Il n’y a pas de troisième option — c’est exactement ce que modélise Either.
Une monade est un conteneur qui permet de chaîner des opérations de manière sécurisée.
// Concept de base : map et flatMap // map : transforme la valeur à l'intérieur sans changer le conteneur // flatMap: transforme la valeur ET peut changer le conteneur Option<Integer> nombre = Option.of(5); Option<Integer> double_ = nombre.map(n -> n * 2); // Option.of(10) Option<String> texte = nombre.map(n -> "Résultat: " + n); // Option.of("Résultat: 5")
Option<T> est un conteneur qui représente soit une valeur présente (Some<T>), soit une absence de valeur (None).
Some<T>
None
C’est l’équivalent VAVR du Optional<T> de Java 8, mais avec beaucoup plus de méthodes utilitaires.
Optional<T>
Option<T> ├── Some<T> — contient une valeur de type T └── None — ne contient rien (jamais null !)
import io.vavr.control.Option; // Depuis une valeur Option<String> avecValeur = Option.of("Bonjour"); // Some("Bonjour") Option<String> sansValeur = Option.of(null); // None (jamais NullPointerException !) Option<String> none = Option.none(); // None (explicite) Option<String> some = Option.some("Monde"); // Some("Monde") // Vérification System.out.println(avecValeur.isDefined()); // true System.out.println(sansValeur.isDefined()); // false System.out.println(sansValeur.isEmpty()); // true
Option<String> option = Option.of("VAVR"); // Méthode 1 : getOrElse — valeur par défaut si None String valeur1 = option.getOrElse("Défaut"); // "VAVR" String valeur2 = Option.none().getOrElse("Défaut"); // "Défaut" // Méthode 2 : getOrElseGet — valeur calculée paresseusement String valeur3 = Option.none().getOrElseGet(() -> calculerValeurParDefaut()); // Méthode 3 : getOrElseThrow — lancer une exception si None String valeur4 = option.getOrElseThrow(() -> new RuntimeException("Valeur absente !")); // Méthode 4 : get() — ATTENTION ! Lance NoSuchElementException si None // À utiliser seulement si vous êtes sûr que la valeur est présente String valeur5 = option.get(); // OK car option est Some
Option<String> nom = Option.of("marie"); // map : transforme la valeur si présente Option<String> nomMajuscule = nom.map(String::toUpperCase); // Some("MARIE") Option<Integer> longueur = nom.map(String::length); // Some(5) // flatMap : pour les transformations qui retournent déjà une Option Option<String> nomValide = nom.flatMap(n -> n.length() > 2 ? Option.of(n) : Option.none() ); // Some("marie") car longueur > 2 // filter : garde la valeur seulement si condition vraie Option<String> nomLong = nom.filter(n -> n.length() > 10); // None (5 <= 10) // peek : effectuer un effet de bord sans modifier (utile pour les logs) nom.peek(n -> System.out.println("Nom trouvé : " + n));
Remarque sur peek() : Utilisez cette méthode pour du logging ou du débogage, on l’utilise pour des effets de bords (affichage d’info intermédiaires sans forcément de rapports avec le résultat ou la finalité d’un Either, Option ou Try).
peek()
Il ne modifie pas la valeur du conteneur : il n’a aucun effet sur la valeur du conteneur. Il permet uniquement d’exécuter une action (comme du logging) sur la valeur.
Si le conteneur est vide (Option.none()) : peek() ne fait rien, (None, Failure, Left), la fonction passée à peek() n’est pas exécutée !
On peut utiliser peek() avec n’importe quel type de Vavr :
// sans VAVR public String getTelephoneUtilisateur(Long id) { User user = userRepository.findById(id); if (user == null) return "Non renseigné"; Contact contact = user.getContact(); if (contact == null) return "Non renseigné"; return contact.getTelephone() != null ? contact.getTelephone() : "Non renseigné"; } // avec VAVR — même logique, code plus expressif public String getTelephoneUtilisateur(Long id) { return Option.of(userRepository.findById(id)) // Option<User> .flatMap(user -> Option.of(user.getContact())) // Option<Contact> .flatMap(contact -> Option.of(contact.getTelephone())) // Option<String> .getOrElse("Non renseigné"); }
Ce qu’il faut retenir : Option rend l’absence de valeur explicite dans le type. Le compilateur vous force à gérer les deux cas.
Les exceptions Java brisent le flux fonctionnel. On ne peut pas faire :
// Ceci ne compile pas — parseInt peut lancer NumberFormatException List<Integer> nombres = List.of("1", "deux", "3") .stream() .map(Integer::parseInt) // ERREUR : exception vérifiée/non vérifiée .collect(Collectors.toList());
Try<T> est un conteneur qui représente soit un succès (Success<T>) soit un échec (Failure<Throwable>).
Success<T>
Failure<Throwable>
Try<T> ├── Success<T> — contient le résultat de type T └── Failure<T> — contient l'exception qui a été levée
import io.vavr.control.Try; // Créer un Try depuis une opération qui peut échouer Try<Integer> resultat1 = Try.of(() -> Integer.parseInt("42")); // Success(42) Try<Integer> resultat2 = Try.of(() -> Integer.parseInt("abc")); // Failure(NumberFormatException) // Vérification System.out.println(resultat1.isSuccess()); // true System.out.println(resultat2.isFailure()); // true // Récupérer la valeur int valeur = resultat1.getOrElse(0); // 42 int defaut = resultat2.getOrElse(0); // 0 // Mapper le résultat si succès Try<String> texte = resultat1.map(n -> "Nombre: " + n); // Success("Nombre: 42") // Récupérer l'exception si échec Throwable exception = resultat2.getCause(); // NumberFormatException
// Lecture d'un fichier, parsing JSON, accès à un champ Try<String> contenuFichier = Try.of(() -> Files.readString(Path.of("config.json"))) .flatMap(contenu -> Try.of(() -> parseJson(contenu))) .map(json -> json.getString("host")) .recover(IOException.class, e -> "localhost") .recover(ParseException.class, e -> "localhost"); String host = contenuFichier.getOrElse("localhost");
// Try et Either sont liés — on peut convertir Try<Integer> tryResult = Try.of(() -> Integer.parseInt("abc")); Either<Throwable, Integer> either = tryResult.toEither(); // Left(NumberFormatException) si échec // Right(42) si succès
Either<L, R> est probablement le type le plus puissant de VAVR pour la gestion d’erreurs métier.
Contrairement à Try qui représente succès/exception, Either représente deux cas quelconques :
Try
Left<L>
Right<R>
Mnémotechnique : “Right is right” (correct/succès). Pensez à “avoir raison” = Right.
Right
Either<L, R> ├── Left<L> — erreur, cas alternatif (Left = "Mauvais") └── Right<R> — succès, résultat attendu (Right = "Correct")
map
flatMap
Exemple : Si une méthode retourne Either<String, User>, vous savez immédiatement à la lecture de la signature que ça peut échouer avec un message String.
Either<String, User>
import io.vavr.control.Either; // Créer un Right (succès) Either<String, Integer> succes = Either.right(42); // Créer un Left (erreur) Either<String, Integer> erreur = Either.left("Le nombre doit être positif"); // Vérification System.out.println(succes.isRight()); // true System.out.println(erreur.isLeft()); // true System.out.println(succes.get()); // 42 System.out.println(erreur.getLeft()); // "Le nombre doit être positif"
public Either<String, Integer> validerAge(int age) { if (age < 0) { return Either.left("L'âge ne peut pas être négatif"); } if (age > 150) { return Either.left("L'âge semble irréaliste"); } return Either.right(age); } // Utilisation Either<String, Integer> resultat = validerAge(25); if (resultat.isRight()) { System.out.println("Âge valide : " + resultat.get()); } else { System.out.println("Erreur : " + resultat.getLeft()); }
map s’applique uniquement sur le Right (le succès). Si c’est un Left, la valeur d’erreur est propagée sans modification.
Left
Either<String, Integer> age = validerAge(25); // map transforme le Right Either<String, String> message = age.map(a -> "Vous avez " + a + " ans"); // Right("Vous avez 25 ans") Either<String, Integer> ageErreur = validerAge(-5); Either<String, String> messageErreur = ageErreur.map(a -> "Vous avez " + a + " ans"); // Left("L'âge ne peut pas être négatif") — le Left est propagé !
Règle fondamentale : Quand vous appelez map sur un Left, le Left est automatiquement propagé. C’est ce qu’on appelle le court-circuit — comme dans un circuit électrique : si un composant est en panne, le courant ne passe pas.
mapLeft fait la même chose mais pour le cas Left :
mapLeft
Either<String, Integer> erreur = Either.left("ERREUR_AGE"); // mapLeft transforme le Left Either<ErrorCode, Integer> avecCode = erreur.mapLeft(msg -> ErrorCode.of(msg)); // Left(ErrorCode.ERREUR_AGE) Either<String, Integer> succes = Either.right(42); Either<ErrorCode, Integer> succesInchange = succes.mapLeft(msg -> ErrorCode.of(msg)); // Right(42) — le Right est propagé sans changement
flatMap permet de chaîner plusieurs opérations qui peuvent chacune échouer. C’est le cœur de la puissance de Either.
public Either<String, User> trouverUtilisateur(Long id) { return Option.of(userRepository.findById(id)) .toEither("Utilisateur introuvable pour l'id " + id); } public Either<String, User> validerUtilisateurActif(User user) { if (!user.isActif()) { return Either.left("L'utilisateur " + user.getEmail() + " est désactivé"); } return Either.right(user); } public Either<String, String> getEmailUtilisateurActif(Long id) { return trouverUtilisateur(id) // Either<String, User> .flatMap(this::validerUtilisateurActif) // Either<String, User> .map(User::getEmail); // Either<String, String> }
Ce qui se passe étape par étape :
1. trouverUtilisateur(1L) → Right(User{id=1, email="alice@ex.com", actif=true}) 2. .flatMap(validerUtilisateurActif) → Right(User{id=1, email="alice@ex.com", actif=true}) (user est actif) 3. .map(User::getEmail) → Right("alice@ex.com") --- Si l'utilisateur n'existe pas --- 1. trouverUtilisateur(999L) → Left("Utilisateur introuvable pour l'id 999") 2. .flatMap(validerUtilisateurActif) → Left("Utilisateur introuvable pour l'id 999") ← COURT-CIRCUIT ! (validerUtilisateurActif n'est JAMAIS appelée) 3. .map(User::getEmail) → Left("Utilisateur introuvable pour l'id 999") ← COURT-CIRCUIT encore !
Point clé : Le court-circuit automatique évite les cascades de if imbriqués. Si une étape échoue, les suivantes sont ignorées.
if
fold permet de gérer les deux cas en une seule expression :
fold
Either<String, Integer> resultat = validerAge(25); // fold : premier argument = fonction pour Left, second = fonction pour Right String message = resultat.fold( erreur -> " Erreur : " + erreur, valeur -> " Valeur valide : " + valeur ); System.out.println(message); // " Valeur valide : 25"
import io.vavr.API.*; import io.vavr.Predicates.*; Either<String, Integer> either = validerAge(-1); String resultat = Match(either).of( Case($(Either::isLeft), e -> "Erreur : " + e.getLeft()), Case($(Either::isRight), e -> "OK : " + e.get()) );
Either<String, Integer> result = Either.left("Erreur"); // orElse : retourne un autre Either si c'est un Left Either<String, Integer> fallback = result.orElse(Either.right(0)); // Right(0) // orElseGet : calcule le fallback paresseusement Either<String, Integer> fallback2 = result.orElseGet(() -> Either.right(calculerValeurDefaut()));
Either<String, Integer> either = Either.right(42); // Vers Option Option<Integer> option = either.toOption(); // Some(42) // Vers Try Try<Integer> tryResult = either.toTry(); // Success(42) // Vers Optional Java standard Optional<Integer> optional = either.toJavaOptional(); // Optional.of(42) // Vers List (utile pour flatMap sur des streams) io.vavr.collection.List<Integer> list = either.toList(); // List(42)
Voici un exemple réaliste qui illustre la puissance de Either :
public class ServicePaiement { // Types d'erreurs métier — bien typés ! public sealed interface ErreurPaiement permits ErreurPaiement.CompteInexistant, ErreurPaiement.SoldeInsuffisant, ErreurPaiement.MontantInvalide, ErreurPaiement.CompteBloque { record CompteInexistant(Long compteId) implements ErreurPaiement {} record SoldeInsuffisant(BigDecimal solde, BigDecimal montant) implements ErreurPaiement {} record MontantInvalide(String raison) implements ErreurPaiement {} record CompteBloque(String raison) implements ErreurPaiement {} } public Either<ErreurPaiement, Paiement> effectuerPaiement( Long compteId, BigDecimal montant) { return validerMontant(montant) .flatMap(m -> trouverCompte(compteId)) .flatMap(this::verifierCompteActif) .flatMap(compte -> verifierSolde(compte, montant)) .flatMap(compte -> executerDebit(compte, montant)); } private Either<ErreurPaiement, BigDecimal> validerMontant(BigDecimal montant) { if (montant == null || montant.compareTo(BigDecimal.ZERO) <= 0) { return Either.left(new ErreurPaiement.MontantInvalide( "Le montant doit être strictement positif" )); } return Either.right(montant); } private Either<ErreurPaiement, Compte> trouverCompte(Long id) { return Option.of(compteRepository.findById(id)) .toEither(new ErreurPaiement.CompteInexistant(id)); } private Either<ErreurPaiement, Compte> verifierCompteActif(Compte compte) { if (compte.isBloque()) { return Either.left(new ErreurPaiement.CompteBloque( "Compte bloqué depuis le " + compte.getDateBlocage() )); } return Either.right(compte); } private Either<ErreurPaiement, Compte> verifierSolde(Compte compte, BigDecimal montant) { if (compte.getSolde().compareTo(montant) < 0) { return Either.left(new ErreurPaiement.SoldeInsuffisant( compte.getSolde(), montant )); } return Either.right(compte); } private Either<ErreurPaiement, Paiement> executerDebit(Compte compte, BigDecimal montant) { return Try.of(() -> { compte.debiter(montant); return compteRepository.save(compte); // ... retourner un objet Paiement }) .toEither() .mapLeft(ex -> new ErreurPaiement.MontantInvalide("Erreur technique: " + ex.getMessage())); } } // Utilisation dans le contrôleur public ResponseEntity<?> payer(Long compteId, BigDecimal montant) { return servicePaiement.effectuerPaiement(compteId, montant) .fold( erreur -> switch (erreur) { case ErreurPaiement.CompteInexistant e -> ResponseEntity.notFound().build(); case ErreurPaiement.SoldeInsuffisant e -> ResponseEntity.badRequest().body("Solde insuffisant: " + e.solde()); case ErreurPaiement.CompteBloque e -> ResponseEntity.status(403).body(e.raison()); case ErreurPaiement.MontantInvalide e -> ResponseEntity.badRequest().body(e.raison()); }, paiement -> ResponseEntity.ok(paiement) ); }
// Transformer une liste d'éléments, en collectant les erreurs List<String> inputs = List.of("1", "deux", "3", "quatre", "5"); // Séparer succès et échecs List<Either<String, Integer>> resultats = inputs.stream() .map(s -> { try { return Either.<String, Integer>right(Integer.parseInt(s)); } catch (NumberFormatException e) { return Either.<String, Integer>left("'" + s + "' n'est pas un nombre"); } }) .toList(); List<Integer> succes = resultats.stream() .filter(Either::isRight) .map(Either::get) .toList(); // [1, 3, 5] List<String> erreurs = resultats.stream() .filter(Either::isLeft) .map(Either::getLeft) .toList(); // ["'deux' n'est pas un nombre", "'quatre' n'est pas un nombre"]
Avec Either, dès qu’une erreur survient, on s’arrête (court-circuit). Cela pose problème pour la validation de formulaires : l’utilisateur veut voir toutes les erreurs en même temps, pas une par une.
// Problème : avec Either, on n'a qu'une erreur à la fois public Either<String, User> validerUser(String nom, String email, int age) { if (nom.isBlank()) return Either.left("Nom requis"); if (!email.contains("@")) return Either.left("Email invalide"); // Jamais vu si nom est vide if (age < 0) return Either.left("Âge invalide"); // Jamais vu si email invalide return Either.right(new User(nom, email, age)); }
Validation<E, T> accumule les erreurs au lieu de s’arrêter à la première.
Validation<E, T> ├── Valid<T> — succès (comme Right) └── Invalid<E> — erreur(s) accumulées (comme Left, mais peut en contenir plusieurs)
import io.vavr.control.Validation; import io.vavr.collection.Seq; public class ValidateurUser { public Validation<String, String> validerNom(String nom) { return (nom != null && !nom.isBlank()) ? Validation.valid(nom) : Validation.invalid("Le nom est requis"); } public Validation<String, String> validerEmail(String email) { return (email != null && email.contains("@")) ? Validation.valid(email) : Validation.invalid("L'email doit contenir '@'"); } public Validation<String, Integer> validerAge(int age) { return (age >= 0 && age <= 150) ? Validation.valid(age) : Validation.invalid("L'âge doit être entre 0 et 150"); } // combineAll — combine plusieurs Validation en accumulant les erreurs public Validation<Seq<String>, User> validerUser(String nom, String email, int age) { return Validation.combine( validerNom(nom), validerEmail(email), validerAge(age) ).ap(User::new); // Seq<String> = liste de toutes les erreurs accumulées } } // Utilisation ValidateurUser validateur = new ValidateurUser(); Validation<Seq<String>, User> resultat = validateur.validerUser("", "email_sans_arobase", -5); if (resultat.isInvalid()) { resultat.getError().forEach(System.out::println); // "Le nom est requis" // "L'email doit contenir '@'" // "L'âge doit être entre 0 et 150" // Toutes les erreurs en une fois ! }
Validation<Seq<String>, User> validation = validateur.validerUser("Alice", "alice@test.com", 30); // Valid → Right, Invalid → Left Either<Seq<String>, User> either = validation.toEither();
Les collections Java standard sont mutables par défaut. Les collections VAVR sont immuables : toute “modification” crée une nouvelle collection.
// Collections VAVR immuables import io.vavr.collection.*; List<Integer> liste = List.of(1, 2, 3, 4, 5); List<Integer> nouvelle = liste.prepend(0); // List(0, 1, 2, 3, 4, 5) // liste est INCHANGÉE : List(1, 2, 3, 4, 5) Map<String, Integer> map = HashMap.of("a", 1, "b", 2); Map<String, Integer> nouvelleMap = map.put("c", 3); // map est INCHANGÉE
List<String> noms = List.of("Alice", "Bob", "Charlie", "Diana", "Eve"); // Filtrer List<String> nomsCourts = noms.filter(n -> n.length() <= 3); // List("Bob", "Eve") // Transformer List<Integer> longueurs = noms.map(String::length); // List(5, 3, 7, 5, 3) // Regrouper Map<Integer, List<String>> parLongueur = noms.groupBy(String::length); // HashMap(3 -> List(Bob, Eve), 5 -> List(Alice, Diana), 7 -> List(Charlie)) // Réduire int totalLongueur = noms.foldLeft(0, (acc, n) -> acc + n.length()); // 23 // Partitionner en succes/echecs (utile avec Either !) List<Either<String, Integer>> resultats = List.of( Either.right(1), Either.left("err"), Either.right(3) ); Map<Boolean, List<Either<String, Integer>>> partitioned = resultats.groupBy(Either::isRight);
Pour utiliser VAVR avec Spring Boot et les sérialisations JSON (Jackson), ajoutez :
<dependency> <groupId>io.vavr</groupId> <artifactId>vavr</artifactId> <version>0.10.4</version> </dependency> <dependency> <groupId>io.vavr</groupId> <artifactId>vavr-jackson</artifactId> <version>0.10.4</version> </dependency>
Configuration Jackson :
@Configuration public class JacksonConfig { @Bean public Jackson2ObjectMapperBuilderCustomizer vavrCustomizer() { return builder -> builder.modulesToInstall(new VavrModule()); } }
@Service public class UtilisateurService { private final UtilisateurRepository repository; // Le type de retour dit TOUT : peut échouer avec une String d'erreur public Either<String, Utilisateur> creerUtilisateur(CreateUserDTO dto) { return validerDTO(dto) .flatMap(this::verifierEmailUnique) .flatMap(this::sauvegarder); } private Either<String, CreateUserDTO> validerDTO(CreateUserDTO dto) { if (dto.email() == null || !dto.email().contains("@")) { return Either.left("Email invalide"); } if (dto.nom() == null || dto.nom().isBlank()) { return Either.left("Nom requis"); } return Either.right(dto); } private Either<String, CreateUserDTO> verifierEmailUnique(CreateUserDTO dto) { boolean emailExiste = repository.existsByEmail(dto.email()); return emailExiste ? Either.left("Email déjà utilisé : " + dto.email()) : Either.right(dto); } private Either<String, Utilisateur> sauvegarder(CreateUserDTO dto) { return Try.of(() -> repository.save(new Utilisateur(dto))) .toEither() .mapLeft(ex -> "Erreur lors de la sauvegarde : " + ex.getMessage()); } }
@RestController @RequestMapping("/api/utilisateurs") public class UtilisateurController { private final UtilisateurService service; @PostMapping public ResponseEntity<?> creer(@RequestBody CreateUserDTO dto) { return service.creerUtilisateur(dto) .fold( // Cas Left (erreur) → 400 Bad Request erreur -> ResponseEntity.badRequest() .body(Map.of("erreur", erreur)), // Cas Right (succès) → 201 Created user -> ResponseEntity.status(201).body(user) ); } @GetMapping("/{id}") public ResponseEntity<?> trouver(@PathVariable Long id) { return service.trouverParId(id) .fold( erreur -> ResponseEntity.notFound().build(), user -> ResponseEntity.ok(user) ); } }
@RestControllerAdvice public class GlobalExceptionHandler { // Utilitaire pour convertir Either en ResponseEntity public static <L, R> ResponseEntity<?> eitherToResponse( Either<L, R> either, Function<L, ResponseEntity<?>> onLeft) { return either.fold( onLeft, body -> ResponseEntity.ok(body) ); } }
@Repository public interface UtilisateurRepository extends JpaRepository<Utilisateur, Long> { boolean existsByEmail(String email); } @Service public class UtilisateurService { public Either<String, Utilisateur> trouverParId(Long id) { // findById retourne Optional<T> — on le convertit en Either return repository.findById(id) .map(Either::<String, Utilisateur>right) .orElse(Either.left("Utilisateur non trouvé (id=" + id + ")")); } // Ou avec VAVR Option public Either<String, Utilisateur> trouverParIdVavr(Long id) { return Option.of(repository.findById(id).orElse(null)) .toEither("Utilisateur non trouvé (id=" + id + ")"); } }
Ne jamais utiliser String comme type d’erreur dans du vrai code ! Utilisez des types dédiés :
// BIEN — types d'erreurs explicites et extensibles public sealed interface AppError { record NotFound(String resource, Object id) implements AppError {} record ValidationError(String field, String message) implements AppError {} record Unauthorized(String reason) implements AppError {} record InternalError(String message, Throwable cause) implements AppError {} } // Usage public Either<AppError, User> trouverUser(Long id) { return Option.of(repo.findById(id).orElse(null)) .toEither(new AppError.NotFound("User", id)); }
Either n’est pas toujours la meilleure solution.
// INUTILE — les exceptions techniques restent des exceptions que Java lancera si vous ne le faites pas ! public Either<String, Integer> diviser(int a, int b) { if (b == 0) throw new ArithmeticException("Division par zéro"); return Either.right(a / b); } // MIEUX et plus PROPRE — Either pour les erreurs MÉTIER public Either<String, Integer> diviser(int a, int b) { if (b == 0) return Either.left("Division par zéro impossible"); return Either.right(a / b); } // MIEUX PLUSPLUS — Try pour les opérations qui peuvent lancer des exceptions sinon, celle du dessus ou pas besoin public Try<Integer> diviserCool(int a, int b) { return Try.of(() -> a / b); }
Validation
public Either<List<String>, User> creerUser(UserDTO dto) { // D'abord valider (accumuler toutes les erreurs) Validation<Seq<String>, UserDTO> validation = Validation.combine( validerNom(dto.nom()), validerEmail(dto.email()), validerAge(dto.age()) ).ap((nom, email, age) -> new UserDTO(nom, email, age)); // puis enchaîner avec la logique métier return validation .toEither() // Validation → Either .mapLeft(seq -> seq.asJava()) // Seq → List<String> .flatMap(this::verifierEmailUnique) .flatMap(this::sauvegarder); }
Le TP complet est disponible dans un fichier à part que je vous mettrai en lien ici, si on décide de le faire.
Some(T)
Success(T)
Failure(Throwable)
Either<L,R>
Right(R)
Left(L)
Validation<E,T>
Valid(T)
Invalid(Seq<E>)
map(f)
flatMap(f)
fold(f, g)
getOrElse(v)
toEither()
mapLeft(f)
recover(f)
Vous avez vu les bases, il vous reste à pratiquer pour maitriser cette syntaxe particulière. Il faut du temps…