Programmation Java

Optional et l'API java.time : gérer correctement l'absence et les dates

Intermédiaire9 min de lectureMis à jour

Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise

Deux API de Java 8 ont éliminé des sources de bugs anciennes : Optional donne un type au « peut-être absent », si bien que l'appelant ne peut plus oublier le cas vide, et java.time remplace les classes Date et Calendar, mutables et confuses sur les fuseaux, par des types immuables aux noms explicites. Ce guide explique ce que signifie chaque type, les opérations que vous utiliserez au quotidien, comment passer de l'un à l'autre, et les erreurs — champs Optional, LocalDateTime pour des horodatages, instances de SimpleDateFormat partagées — qui reviennent sans cesse en revue de code.

Ce que vous allez apprendre

  • Utiliser Optional comme type de retour et enchaîner map, flatMap, filter et orElse sans jamais appeler get
  • Expliquer la différence entre Instant, LocalDateTime, ZonedDateTime et OffsetDateTime
  • Choisir correctement entre Duration et Period et faire de l'arithmétique de dates qui respecte les fuseaux horaires
  • Formater et parser des dates avec DateTimeFormatter, y compris en ISO-8601
  • Convertir entre java.time, millisecondes epoch, java.util.Date et types JDBC

Avant Java 8, deux catégories de bugs étaient endémiques dans le code Java : la NullPointerException provoquée par une méthode qui renvoyait null pour dire « rien trouvé » à un appelant qui oubliait de le vérifier, et la date fausse, sans bruit, d'une heure, d'un jour ou d'un fuseau, parce que java.util.Date n'était qu'un compteur de millisecondes mutable qui se faisait passer pour un calendrier. Optional et java.time sont les réponses à ces deux problèmes. Ce sont deux petites API, dotées de règles d'usage bien établies, que ce guide expose. Le code cible Java 21.

Optional : un type pour « peut-être absent »

Optional<T> est un conteneur : soit il porte une valeur non nulle, soit il est vide. Son rôle est étroit : utilisé comme type de retour, il annonce dans la signature même qu'un résultat peut manquer, et son API pousse l'appelant à traiter ce cas là où il aurait sauté le test de null.

public Optional<Customer> findByEmail(String email) {
    Customer c = index.get(email.toLowerCase());
    return Optional.ofNullable(c);              // empty if null
}

// Caller
String greeting = findByEmail("[email protected]")
        .map(Customer::firstName)               // Optional<String>, still empty if no customer
        .filter(name -> !name.isBlank())
        .map(name -> "Hello, " + name)
        .orElse("Hello");

Créez-en un avec Optional.of(value) (qui lève une exception sur null), Optional.ofNullable(value) ou Optional.empty(). Consommez-le ensuite au moyen des combinateurs plutôt qu'en l'inspectant :

MéthodeUsage
map(fn)Transforme la valeur si elle est présente ; le résultat est un Optional<R>
flatMap(fn)Comme map lorsque fn renvoie elle-même un Optional, pour éviter Optional<Optional<R>>
filter(pred)Devient vide si le prédicat échoue
orElse(v)La valeur ou une valeur de repli ; v est toujours évalué
orElseGet(supplier)La valeur ou une valeur de repli calculée paresseusement
orElseThrow() / orElseThrow(supplier)La valeur ou une exception (la forme sans argument lève NoSuchElementException)
ifPresent(consumer) / ifPresentOrElse(consumer, runnable)Effets de bord pour chacun des cas
or(supplier)Un autre Optional si celui-ci est vide (Java 9)
stream()Un stream de zéro ou un élément, pratique dans un flatMap (Java 9)

La différence entre orElse et orElseGet compte quand la valeur de repli est coûteuse : orElse(loadDefault()) appelle loadDefault() à chaque fois, même quand la valeur est présente.

isPresent() suivi de get() est le réflexe à désapprendre. C'est un test de null avec plus de caractères, et get() lève NoSuchElementException sur un Optional vide, ce qui ne vaut pas mieux qu'une NPE. get() n'a été conservé que pour la compatibilité ; orElseThrow() dit la même chose, mais honnêtement.

Là où Optional n'a pas sa place

  • Les champs. Optional n'est pas Serializable, coûte une allocation par objet, et le champ peut quand même valoir null. Utilisez un champ nullable et renvoyez Optional.ofNullable(field) depuis l'accesseur si vous voulez que l'appelant voie l'absence.
  • Les paramètres de méthode. L'appelant doit envelopper chaque argument. Proposez une surcharge ou acceptez une valeur nullable.
  • Les collections. List<Optional<String>> comme Optional<List<String>> sont moins bons qu'une liste tout simplement vide.
  • Les chemins critiques en performance. C'est un objet ; dans une boucle serrée, renvoyer null avec un contrat documenté, ou une valeur sentinelle primitive, peut se justifier. OptionalInt, OptionalLong et OptionalDouble existent pour les résultats primitifs : c'est ce que renvoie IntStream.max().

Un test utile : si vous ne pouvez pas écrire le code avec map, flatMap, filter et un seul orElse* terminal, vous utilisez probablement Optional comme un null déguisé.

java.time : le modèle

Les classes d'avant Java 8 n'avaient qu'un seul type, Date, pour tous les concepts ; il était mutable, numérotait les mois à partir de zéro et était secrètement en UTC. java.time donne à chaque concept son propre type immuable. Savoir lequel utiliser représente l'essentiel du travail.

TypeReprésenteExempleÀ utiliser pour
InstantUn point sur la ligne du temps UTC2026-09-07T10:15:30ZHorodatages, logs, « quand est-ce arrivé »
LocalDateUne date du calendrier, sans heure ni fuseau2026-09-07Anniversaires, échéances, jours ouvrés
LocalTimeUne heure murale, sans date ni fuseau10:15:30Horaires d'ouverture, alarmes
LocalDateTimeDate et heure, sans fuseau2026-09-07T10:15:30Valeurs volontairement indépendantes du fuseau (une réunion récurrente à 9 h)
ZonedDateTimeDate, heure et fuseau régional avec ses règles d'heure d'été2026-09-07T10:15:30+02:00[Europe/Paris]Affichage à un utilisateur, planification dans une région
OffsetDateTimeDate, heure et décalage fixe par rapport à UTC2026-09-07T10:15:30+02:00Échanges (JSON, bases de données) où le décalage est connu mais pas la région
DurationQuantité de temps en secondes/nanosecondesPT1H30MTimeouts, temps écoulé
PeriodQuantité de temps en années/mois/joursP1M10DDurées d'abonnement, âge
ZoneId / ZoneOffsetUne région (Europe/Paris) ou un décalage fixe (+02:00)Conversions entre les types ci-dessus
Year, YearMonth, MonthDay, DayOfWeek, MonthDates partielles2026-09Expiration de carte bancaire, rapports

La distinction la plus importante : un LocalDateTime n'est pas un instant. « 2026-03-29T02:30 » n'existe pas à Paris (les horloges sautent ce créneau) et « 2026-10-25T02:30 » s'y produit deux fois. Seuls Instant, ZonedDateTime et OffsetDateTime désignent un instant unique. Stockez et transmettez des Instant (ou des OffsetDateTime lorsque l'API exige un décalage) ; utilisez ZonedDateTime quand le calendrier local d'une personne compte ; réservez LocalDateTime aux cas où l'absence de fuseau est précisément le but.

Instant now = Instant.now();                                          // UTC, from the system clock
ZonedDateTime paris = now.atZone(ZoneId.of("Europe/Paris"));          // same moment, local view
LocalDate today = paris.toLocalDate();                                // the date in Paris
ZonedDateTime nextNine = paris.plusDays(1).withHour(9).withMinute(0).withSecond(0).withNano(0);
Instant back = nextNine.toInstant();                                  // for storage

Tous ces types sont immuables : plusDays renvoie un nouvel objet et laisse l'original intact. Oublier d'utiliser la valeur de retour est un bug silencieux fréquent : date.plusDays(1); seul ne fait rien.

Horloges et testabilité

Instant.now(), LocalDate.now() et consorts lisent l'horloge système, ce qui rend le code qui les utilise impossible à tester de façon déterministe. Chaque now() possède une surcharge qui prend un Clock : injectez-en un (Clock.systemUTC() en production, Clock.fixed(instant, zone) dans les tests) et la logique dépendante du temps devient une fonction pure de ses entrées.

Arithmétique : Duration ou Period

Duration mesure exactement le temps écoulé ; Period mesure une distance calendaire. Ajouter une Duration de 24 heures à un ZonedDateTime au passage d'un changement d'heure donne une autre heure murale ; ajouter une Period d'un jour donne la même heure murale à la date suivante. Les deux sont corrects — ils répondent à des questions différentes.

ZonedDateTime beforeShift = ZonedDateTime.of(2026, 10, 24, 12, 0, 0, 0, ZoneId.of("Europe/Paris"));
beforeShift.plus(Duration.ofHours(24));   // 2026-10-25T11:00+01:00 — 24 real hours later
beforeShift.plus(Period.ofDays(1));       // 2026-10-25T12:00+01:00 — same time next day

Duration elapsed = Duration.between(start, Instant.now());    // for timing
long ms = elapsed.toMillis();

Period age = Period.between(LocalDate.of(1990, 4, 12), LocalDate.now());
int years = age.getYears();

long daysUntil = ChronoUnit.DAYS.between(LocalDate.now(), deadline);   // whole units between two temporals

ChronoUnit.X.between(a, b) est la tournure idiomatique pour « combien d'unités entières d'écart », et il tronque au lieu d'arrondir. L'arithmétique sur LocalDate se cale sur la fin du mois : le 31 janvier plus un mois donne le 28 (ou 29) février, et un mois de plus donne le 28 mars, pas le 31 — alors qu'une Period de deux mois appliquée d'un coup donne le 31 mars. Si le comportement en fin de mois compte, calculez toujours à partir de la date d'origine.

Formatage et parsing

DateTimeFormatter gère les deux sens et il est immuable : gardez-en une instance dans un champ static final. Les formateurs ISO prédéfinis couvrent les formats d'échange :

static final DateTimeFormatter HUMAN = DateTimeFormatter.ofPattern("EEEE d MMMM uuuu, HH:mm", Locale.UK);

String iso = Instant.now().toString();                        // 2026-09-07T10:15:30.123456Z
String forUser = ZonedDateTime.now(ZoneId.of("Europe/London")).format(HUMAN);
                                                              // Monday 7 September 2026, 11:15

LocalDate d = LocalDate.parse("2026-09-07");                 // ISO_LOCAL_DATE by default
Instant i = Instant.parse("2026-09-07T10:15:30Z");
OffsetDateTime o = OffsetDateTime.parse("2026-09-07T12:15:30+02:00");
LocalDate custom = LocalDate.parse("07/09/2026", DateTimeFormatter.ofPattern("dd/MM/uuuu"));

Les lettres de motif à ne pas confondre : uuuu est l'année proleptique, plus sûre que yyyy (l'année de l'ère, qui exige une ère pour être non ambiguë en mode strict) ; MM est le mois et mm la minute ; HH est l'heure sur 24 heures, tandis que hh exige a pour AM/PM ; EEEE est le nom complet du jour. Passez toujours une Locale quand le motif contient des noms, sinon le résultat dépend de la locale par défaut du serveur.

Le parsing est strict par défaut avec les formateurs ISO, mais utilise ResolverStyle.SMART avec les motifs, ce qui accepte le 31 avril en le transformant en 30 avril. Utilisez .withResolverStyle(ResolverStyle.STRICT) (et uuuu, puisque yyyy en mode strict exige une ère) lorsque les dates invalides doivent être rejetées. Une DateTimeParseException — non vérifiée — indique la position de l'échec.

Pour les API, émettez de l'ISO-8601 avec un décalage ou Z (le profil défini par la RFC 3339). Jackson sérialise un Instant en chaîne ISO à condition que le module jackson-datatype-jsr310 soit enregistré et que WRITE_DATES_AS_TIMESTAMPS soit désactivé — Spring Boot fait les deux ; avec le module seul, vous obtenez un nombre de secondes décimal, et sans lui les versions récentes de Jackson refusent de sérialiser le type.

Interopérer avec les types hérités et externes

Vous croiserez java.util.Date dans les vieilles bibliothèques, les drivers JDBC et le code fondé sur Calendar. Convertissez à la frontière et gardez java.time partout ailleurs :

Date legacy = Date.from(instant);
Instant fromLegacy = legacy.toInstant();

long epochMillis = instant.toEpochMilli();
Instant fromMillis = Instant.ofEpochMilli(epochMillis);
long epochSeconds = instant.getEpochSecond();

// JDBC 4.2+: pass java.time types directly
preparedStatement.setObject(1, offsetDateTime);          // TIMESTAMP WITH TIME ZONE
LocalDate d = resultSet.getObject("due_date", LocalDate.class);

java.sql.Timestamp, java.sql.Date et java.sql.Time sont des sous-classes de java.util.Date au comportement encore plus étrange (java.sql.Date lève une exception sur les méthodes liées à l'heure) et n'ont rien à faire dans le code applicatif : JDBC 4.2 et tous les drivers actuels acceptent LocalDate, LocalTime, LocalDateTime, OffsetDateTime et Instant via setObject/getObject. Pour les valeurs epoch présentes dans du JSON ou des logs, le convertisseur de timestamp transforme les millisecondes en dates lisibles pendant le débogage.

Les données de fuseaux horaires sont livrées avec le JDK (les règles tzdata) et mises à jour par les versions correctives du JDK. Les gouvernements modifient les règles d'heure d'été avec un préavis très court ; un JDK non mis à jour calcule de mauvaises heures locales pour les régions concernées.

Erreurs fréquentes

Optional.get() sans vérification. Lève NoSuchElementException ; utilisez orElseThrow avec une exception parlante, ou un combinateur.

Optional en champ, en paramètre ou dans une collection. Mauvais outil ; voir plus haut.

LocalDateTime pour un horodatage. Il n'enregistre aucun fuseau : l'instant est ambigu, et les changements d'heure produisent des valeurs impossibles ou en double. Stockez un Instant.

Ignorer la valeur de retour. date.plusDays(1); jette le résultat, car tous les types java.time sont immuables.

Partager un SimpleDateFormat. Il est mutable et n'est pas thread-safe : deux threads corrompent mutuellement leurs résultats. DateTimeFormatter, lui, se partage sans risque.

yyyy en parsing strict. L'année de l'ère exige une ère ; utilisez uuuu.

Comparer des ZonedDateTime avec equals. Deux objets représentant le même instant dans des fuseaux différents ne sont pas equals. Utilisez isEqual, isBefore, isAfter, ou comparez toInstant().

Supposer qu'un jour dure 24 heures. Utilisez Period pour les jours calendaires et Duration pour les heures écoulées ; ne multipliez pas par 86_400_000.

ZoneId.systemDefault() dans du code serveur. Il dépend de l'endroit où tourne la JVM. Passez explicitement le fuseau de l'utilisateur ou de l'entreprise, et faites tourner les serveurs en UTC.

Points clés à retenir

  • Optional est un type de retour qui rend l'absence explicite ; consommez-le avec map, flatMap, filter et orElse*, jamais avec isPresent/get, et tenez-le à l'écart des champs, des paramètres et des collections.
  • Seuls Instant, ZonedDateTime et OffsetDateTime désignent un instant unique ; LocalDateTime est une heure murale sans fuseau et ne convient pas aux horodatages.
  • Stockez et échangez des Instant (ou de l'ISO-8601 avec décalage) ; convertissez en ZonedDateTime en bout de chaîne, là où une personne lit la valeur.
  • Duration est un temps écoulé exact, Period une distance calendaire : ils divergent, par conception, lors des changements d'heure et en fin de mois.
  • Tous les types java.time sont immuables et thread-safe ; déclarez vos DateTimeFormatter en static, passez une Locale et préférez uuuu avec une résolution stricte pour valider les saisies.
  • Convertissez les Date hérités, les millisecondes epoch et les valeurs JDBC à la frontière, et injectez un Clock pour rendre testable le code dépendant du temps.

Sources et lectures complémentaires

Questions fréquentes

Faut-il utiliser Optional pour un champ ou un paramètre de méthode ?
Non. Optional a été conçu comme type de retour pour les méthodes qui peuvent légitimement ne rien renvoyer. En champ, il n'est pas sérialisable, ajoute une allocation et exige encore un test de null, puisque la référence vers l'Optional peut elle-même être null. En paramètre, il oblige l'appelant à envelopper ses valeurs ; une surcharge, ou un paramètre nullable documenté avec @Nullable, est plus clair.
Dans quel type java.time stocker un horodatage ?
Instant. C'est un point sur la ligne du temps UTC, sans aucune ambiguïté de fuseau, qui correspond directement à TIMESTAMP WITH TIME ZONE dans PostgreSQL ou à des millisecondes epoch. Ne convertissez en ZonedDateTime qu'en bout de chaîne, là où un humain lit la valeur. LocalDateTime ne convient pas aux horodatages, car il ne dit pas de quel instant il s'agit.
Quelle est la différence entre Duration et Period ?
Duration est une quantité de temps exacte, en secondes et nanosecondes : 90 minutes font toujours 5 400 secondes. Period est une quantité calendaire, en années, mois et jours : un mois dure de 28 à 31 jours, et un jour peut durer 23 ou 25 heures lors d'un changement d'heure. Utilisez Duration pour l'arithmétique sur le temps écoulé et Period pour l'arithmétique sur les dates.
DateTimeFormatter est-il thread-safe ?
Oui. Contrairement à l'ancien SimpleDateFormat, qui produit des résultats corrompus lorsqu'il est partagé entre threads, DateTimeFormatter est immuable et peut sans risque être conservé dans un champ static final. Créez-le une seule fois à partir d'un motif, ou utilisez les constantes ISO prédéfinies.