Programación en Java

Optional y la API java.time: cómo manejar bien la ausencia y las fechas

Intermedio8 min de lecturaActualizado

Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés

Dos API de Java 8 acabaron con fuentes de bugs muy antiguas: Optional da un tipo al «quizá no esté», de modo que quien llama no puede olvidarse del caso vacío, y java.time sustituye las clases Date y Calendar, mutables y confusas con las zonas horarias, por tipos inmutables con nombres claros. Esta guía explica qué significa cada tipo, las operaciones que usarás a diario, cómo convertir entre ellos y los errores —campos Optional, LocalDateTime para marcas de tiempo, instancias compartidas de SimpleDateFormat— que siguen apareciendo en las revisiones de código.

Lo que aprenderás

  • Usar Optional como tipo de retorno y encadenar map, flatMap, filter y orElse sin llamar a get
  • Explicar la diferencia entre Instant, LocalDateTime, ZonedDateTime y OffsetDateTime
  • Elegir correctamente entre Duration y Period y hacer aritmética de fechas que respete las zonas horarias
  • Formatear y parsear fechas con DateTimeFormatter, incluido ISO-8601
  • Convertir entre java.time, milisegundos epoch, java.util.Date y tipos JDBC

Antes de Java 8 había dos categorías de bugs endémicas en el código Java: la NullPointerException de un método que devolvía null para decir «no hay nada» a quien llamaba y se olvidaba de comprobarlo, y la fecha que se desviaba en silencio una hora, un día o una zona horaria porque java.util.Date era un contador de milisegundos mutable que se hacía pasar por calendario. Optional y java.time son la respuesta. Ambas son API pequeñas y ambas tienen reglas de uso bien asentadas, que esta guía expone. El código está pensado para Java 21.

Optional: un tipo para «quizá no esté»

Optional<T> es un contenedor que o bien guarda un valor no nulo, o bien está vacío. Su propósito está acotado: como tipo de retorno, indica en la propia firma que el resultado puede faltar, y su API empuja a quien llama a tratar ese caso donde antes se habría saltado la comprobación 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éalo con Optional.of(value) (lanza una excepción si recibe null), Optional.ofNullable(value) u Optional.empty(). Después consúmelo mediante los combinadores, en lugar de inspeccionarlo:

MétodoUso
map(fn)Transforma el valor si existe; el resultado es Optional<R>
flatMap(fn)Como map cuando fn ya devuelve un Optional, para evitar Optional<Optional<R>>
filter(pred)Queda vacío si el predicado no se cumple
orElse(v)El valor o uno por defecto; v se evalúa siempre
orElseGet(supplier)El valor o uno por defecto calculado de forma perezosa
orElseThrow() / orElseThrow(supplier)El valor o una excepción (la forma sin argumentos lanza NoSuchElementException)
ifPresent(consumer) / ifPresentOrElse(consumer, runnable)Efectos secundarios para cada caso
or(supplier)Otro Optional si este está vacío (Java 9)
stream()Un stream de cero o un elemento, útil dentro de flatMap (Java 9)

La diferencia entre orElse y orElseGet importa cuando el valor por defecto es caro: orElse(loadDefault()) llama a loadDefault() siempre, incluso cuando el valor está presente.

isPresent() seguido de get() es el patrón que hay que desaprender. Es una comprobación de null con más caracteres, y get() lanza NoSuchElementException si está vacío, lo que no es mejor que una NPE. get() se mantuvo por compatibilidad; orElseThrow() dice lo mismo, pero con honestidad.

Dónde no debe usarse Optional

  • Campos. Optional no es Serializable, cuesta una asignación de memoria por objeto y el campo puede seguir siendo null. Usa un campo que admita null y devuelve Optional.ofNullable(field) desde el getter si quieres que quien llama vea la ausencia.
  • Parámetros de métodos. Quien llama tiene que envolver cada argumento. Ofrece una sobrecarga o acepta un valor que pueda ser null.
  • Colecciones. Tanto List<Optional<String>> como Optional<List<String>> son peores que una lista que simplemente está vacía.
  • Rutas críticas para el rendimiento. Es un objeto; en un bucle ajustado puede estar justificado devolver null con un contrato documentado, o un valor centinela primitivo. OptionalInt, OptionalLong y OptionalDouble existen para resultados primitivos y son lo que devuelve IntStream.max().

Una prueba útil: si no puedes expresar el código con map, flatMap, filter y un único orElse* final, probablemente estás usando Optional como un null con otro nombre.

java.time: el modelo

Las clases anteriores a Java 8 tenían un único tipo, Date, para todos los conceptos, y era mutable, numeraba los meses desde cero y era UTC a escondidas. java.time da a cada concepto su propio tipo inmutable. Aprender cuál usar es la mayor parte del trabajo.

TipoRepresentaEjemploÚsalo para
InstantUn punto en la línea temporal UTC2026-09-07T10:15:30ZMarcas de tiempo, logs, «cuándo ocurrió»
LocalDateUna fecha de calendario, sin hora ni zona2026-09-07Cumpleaños, fechas de vencimiento, días hábiles
LocalTimeUna hora de reloj, sin fecha ni zona10:15:30Horarios de apertura, alarmas
LocalDateTimeFecha y hora, sin zona2026-09-07T10:15:30Valores que carecen de zona a propósito (una reunión recurrente a las 09:00)
ZonedDateTimeFecha, hora y una zona regional con sus reglas de horario de verano2026-09-07T10:15:30+02:00[Europe/Paris]Mostrar datos a un usuario, planificar en una región
OffsetDateTimeFecha, hora y un desfase fijo respecto a UTC2026-09-07T10:15:30+02:00Intercambio (JSON, bases de datos) cuando se conoce el desfase pero no la región
DurationCantidad de tiempo en segundos/nanosPT1H30MTimeouts, tiempo transcurrido
PeriodCantidad de tiempo en años/meses/díasP1M10DDuración de suscripciones, edad
ZoneId / ZoneOffsetUna región (Europe/Paris) o un desfase fijo (+02:00)Convertir entre los anteriores
Year, YearMonth, MonthDay, DayOfWeek, MonthFechas parciales2026-09Caducidad de tarjetas de crédito, informes

La distinción más importante: un LocalDateTime no es un momento. «2026-03-29T02:30» no existe en París (los relojes se saltan esa hora) y «2026-10-25T02:30» ocurre dos veces. Solo Instant, ZonedDateTime y OffsetDateTime identifican un momento único. Guarda y transmite Instant (u OffsetDateTime cuando la API exige un desfase); usa ZonedDateTime cuando importe el calendario local de una persona; y reserva LocalDateTime para cuando la ausencia de zona sea justo lo que buscas.

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

Todos estos tipos son inmutables: plusDays devuelve un objeto nuevo y deja el original intacto. Olvidarse de usar el valor devuelto es un bug silencioso habitual: date.plusDays(1); por sí solo no hace nada.

Relojes y testabilidad

Instant.now(), LocalDate.now() y compañía leen el reloj del sistema, lo que hace imposible probar de forma determinista el código que los usa. Cada now() tiene una sobrecarga que recibe un Clock; inyecta uno (Clock.systemUTC() en producción, Clock.fixed(instant, zone) en los tests) y la lógica dependiente del tiempo se convierte en una función pura de sus entradas.

Aritmética: Duration frente a Period

Duration mide el tiempo transcurrido de forma exacta; Period mide distancia en el calendario. Sumar una Duration de 24 horas a un ZonedDateTime que atraviesa un cambio de horario de verano da otra hora de reloj; sumar un Period de un día da la misma hora de reloj en la fecha siguiente. Ambos resultados son correctos, pero responden a preguntas distintas.

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) es la forma idiomática de preguntar «cuántas unidades completas hay entre ambos», y trunca en lugar de redondear. La aritmética de LocalDate se ajusta al final de mes: el 31 de enero más un mes es el 28 (o 29) de febrero, y sumar otro mes da el 28 de marzo, no el 31, mientras que un Period de dos meses aplicado de una vez da el 31 de marzo. Si el comportamiento a fin de mes importa, calcula siempre desde la fecha original.

Formateo y parseo

DateTimeFormatter funciona en ambos sentidos y es inmutable, así que guárdalo en un campo static final. Los formateadores ISO predefinidos cubren los formatos de intercambio:

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"));

Las letras de patrón que hay que tener claras: uuuu es el año proléptico y es más seguro que yyyy (año de la era, que necesita una era para no ser ambiguo en modo estricto); MM es el mes y mm el minuto; HH es la hora en formato de 24 horas, mientras que hh necesita a para AM/PM; EEEE es el nombre completo del día. Pasa siempre un Locale cuando el patrón incluya nombres; si no, la salida dependerá del locale por defecto del servidor.

El parseo es estricto por defecto con los formateadores ISO y usa ResolverStyle.SMART con los patrones, que acepta el 31 de abril y lo convierte en 30 de abril. Usa .withResolverStyle(ResolverStyle.STRICT) (y uuuu, ya que yyyy en modo estricto exige una era) cuando haya que rechazar fechas no válidas. Una DateTimeParseException, que es unchecked, indica la posición en la que falló.

En las API, emite ISO-8601 con desfase o Z (el perfil definido en la RFC 3339). Jackson serializa Instant como cadena ISO una vez registrado el módulo jackson-datatype-jsr310 y desactivado WRITE_DATES_AS_TIMESTAMPS; Spring Boot hace ambas cosas. Solo con el módulo obtienes un número de segundos con decimales, y sin él las versiones recientes de Jackson se niegan a serializar el tipo.

Interoperar con tipos heredados y externos

Te encontrarás java.util.Date en bibliotecas antiguas, drivers JDBC y código basado en Calendar. Convierte en la frontera y usa java.time en todo lo demás:

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 y java.sql.Time son subclases de java.util.Date con un comportamiento aún más extraño (java.sql.Date lanza una excepción en los métodos de hora del día) y no deberían aparecer en el código de la aplicación; JDBC 4.2 y todos los drivers actuales aceptan LocalDate, LocalTime, LocalDateTime, OffsetDateTime e Instant mediante setObject/getObject. Para valores epoch en JSON o en logs, el conversor de timestamps transforma milisegundos en fechas legibles mientras depuras.

Los datos de zonas horarias vienen dentro del JDK (las reglas tzdata) y se actualizan con las versiones de parche del JDK. Los gobiernos cambian las reglas del horario de verano con poca antelación; un JDK sin parchear calcula horas locales erróneas para esas regiones.

Errores comunes

Optional.get() sin comprobar. Lanza NoSuchElementException; usa orElseThrow con una excepción significativa o un combinador.

Optional en campos, parámetros y colecciones. Herramienta equivocada; consulta lo explicado más arriba.

LocalDateTime para una marca de tiempo. No registra ninguna zona, así que el momento es ambiguo y los cambios de horario producen valores imposibles o duplicados. Guarda un Instant.

Ignorar el valor devuelto. date.plusDays(1); descarta el resultado porque todos los tipos de java.time son inmutables.

Compartir SimpleDateFormat. Es mutable y no es thread-safe; dos threads se corrompen mutuamente la salida. DateTimeFormatter sí puede compartirse sin riesgo.

yyyy con parseo estricto. El año de la era necesita una era; usa uuuu.

Comparar ZonedDateTime con equals. Dos objetos que representan el mismo instante en zonas distintas no son equals. Usa isEqual, isBefore, isAfter o compara toInstant().

Dar por hecho que un día tiene 24 horas. Usa Period para días de calendario y Duration para horas transcurridas; no multipliques por 86_400_000.

ZoneId.systemDefault() en código de servidor. Depende de dónde se ejecute la JVM. Pasa de forma explícita la zona del usuario o del negocio y ejecuta los servidores en UTC.

Puntos clave

  • Optional es un tipo de retorno que hace explícita la ausencia; consúmelo con map, flatMap, filter y orElse*, nunca con isPresent/get, y mantenlo fuera de campos, parámetros y colecciones.
  • Solo Instant, ZonedDateTime y OffsetDateTime denotan un momento único; LocalDateTime es una hora de reloj sin zona y no sirve para marcas de tiempo.
  • Guarda e intercambia Instant (o ISO-8601 con desfase); convierte a ZonedDateTime en el extremo donde lo lee una persona.
  • Duration es tiempo transcurrido exacto; Period es distancia en el calendario. Difieren, por diseño, en los cambios de horario y a fin de mes.
  • Todos los tipos de java.time son inmutables y thread-safe; mantén los DateTimeFormatter como estáticos, pasa un Locale y prefiere uuuu con resolución estricta al validar entradas.
  • Convierte los Date heredados, los milisegundos epoch y los valores JDBC en la frontera, e inyecta un Clock para que el código dependiente del tiempo se pueda probar.

Fuentes y lecturas recomendadas

Preguntas frecuentes

¿Debo usar Optional como campo o como parámetro de un método?
No. Optional se diseñó como tipo de retorno para métodos que pueden, legítimamente, no tener resultado. Como campo no es serializable, añade una asignación de memoria y sigue necesitando una comprobación de null, porque la propia referencia al Optional podría ser null. Como parámetro obliga a quien llama a envolver los valores; una sobrecarga, o un parámetro que admita null documentado con @Nullable, resulta más claro.
¿En qué tipo de java.time debo guardar una marca de tiempo?
En Instant. Es un punto en la línea temporal UTC, sin ambigüedad de zona, y se corresponde directamente con TIMESTAMP WITH TIME ZONE en PostgreSQL o con milisegundos epoch. Convierte a ZonedDateTime solo en el extremo donde una persona lo lee. LocalDateTime no sirve para marcas de tiempo porque no puede indicar a qué momento se refiere.
¿Qué diferencia hay entre Duration y Period?
Duration es una cantidad exacta de tiempo en segundos y nanosegundos: 90 minutos son siempre 5400 segundos. Period es una cantidad de calendario en años, meses y días: un mes tiene entre 28 y 31 días, y un día puede durar 23 o 25 horas cuando cambia el horario de verano. Usa Duration para la aritmética basada en tiempo y Period para la basada en fechas.
¿DateTimeFormatter es thread-safe?
Sí. A diferencia del antiguo SimpleDateFormat, que corrompe la salida cuando se comparte entre threads, DateTimeFormatter es inmutable y puede guardarse sin problema en un campo static final. Créalo una sola vez con un patrón o usa las constantes ISO predefinidas.