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étodo | Uso |
|---|---|
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.
Optionalno esSerializable, cuesta una asignación de memoria por objeto y el campo puede seguir siendonull. Usa un campo que admita null y devuelveOptional.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>>comoOptional<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
nullcon un contrato documentado, o un valor centinela primitivo.OptionalInt,OptionalLongyOptionalDoubleexisten para resultados primitivos y son lo que devuelveIntStream.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.
| Tipo | Representa | Ejemplo | Úsalo para |
|---|---|---|---|
Instant | Un punto en la línea temporal UTC | 2026-09-07T10:15:30Z | Marcas de tiempo, logs, «cuándo ocurrió» |
LocalDate | Una fecha de calendario, sin hora ni zona | 2026-09-07 | Cumpleaños, fechas de vencimiento, días hábiles |
LocalTime | Una hora de reloj, sin fecha ni zona | 10:15:30 | Horarios de apertura, alarmas |
LocalDateTime | Fecha y hora, sin zona | 2026-09-07T10:15:30 | Valores que carecen de zona a propósito (una reunión recurrente a las 09:00) |
ZonedDateTime | Fecha, hora y una zona regional con sus reglas de horario de verano | 2026-09-07T10:15:30+02:00[Europe/Paris] | Mostrar datos a un usuario, planificar en una región |
OffsetDateTime | Fecha, hora y un desfase fijo respecto a UTC | 2026-09-07T10:15:30+02:00 | Intercambio (JSON, bases de datos) cuando se conoce el desfase pero no la región |
Duration | Cantidad de tiempo en segundos/nanos | PT1H30M | Timeouts, tiempo transcurrido |
Period | Cantidad de tiempo en años/meses/días | P1M10D | Duración de suscripciones, edad |
ZoneId / ZoneOffset | Una región (Europe/Paris) o un desfase fijo (+02:00) | Convertir entre los anteriores | |
Year, YearMonth, MonthDay, DayOfWeek, Month | Fechas parciales | 2026-09 | Caducidad 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
Optionales un tipo de retorno que hace explícita la ausencia; consúmelo conmap,flatMap,filteryorElse*, nunca conisPresent/get, y mantenlo fuera de campos, parámetros y colecciones.- Solo
Instant,ZonedDateTimeyOffsetDateTimedenotan un momento único;LocalDateTimees una hora de reloj sin zona y no sirve para marcas de tiempo. - Guarda e intercambia
Instant(o ISO-8601 con desfase); convierte aZonedDateTimeen el extremo donde lo lee una persona. Durationes tiempo transcurrido exacto;Periodes distancia en el calendario. Difieren, por diseño, en los cambios de horario y a fin de mes.- Todos los tipos de
java.timeson inmutables y thread-safe; mantén losDateTimeFormattercomo estáticos, pasa unLocaley prefiereuuuucon resolución estricta al validar entradas. - Convierte los
Dateheredados, los milisegundos epoch y los valores JDBC en la frontera, e inyecta unClockpara 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.