Programação Java
Optional e a API java.time: como lidar direito com ausência e datas
Intermediário8 min de leituraAtualizado
Esta página foi traduzida do inglês com a ajuda de IA e conferida com o original. Ler a versão em inglês
Duas APIs do Java 8 eliminaram fontes antigas de bugs: Optional dá um tipo ao 'talvez não exista', de modo que quem chama não consegue esquecer o caso vazio, e java.time substitui as classes Date e Calendar, mutáveis e confusas em relação a fusos, por tipos imutáveis com nomes claros. Este guia explica o que cada tipo significa, as operações que você vai usar no dia a dia, como converter entre eles e os erros — campos Optional, LocalDateTime para timestamps, instâncias compartilhadas de SimpleDateFormat — que continuam aparecendo em code reviews.
O que você vai aprender
- Usar Optional como tipo de retorno e encadear map, flatMap, filter e orElse sem chamar get
- Explicar a diferença entre Instant, LocalDateTime, ZonedDateTime e OffsetDateTime
- Escolher corretamente entre Duration e Period e fazer aritmética de datas respeitando fusos horários
- Formatar e fazer parse de datas com DateTimeFormatter, incluindo ISO-8601
- Converter entre java.time, milissegundos epoch, java.util.Date e tipos JDBC
Antes do Java 8, duas categorias de bug eram endêmicas no código Java: a NullPointerException de um método que devolvia null para dizer "não encontrei nada" a quem chamava e esquecia de checar, e a data silenciosamente errada por uma hora, um dia ou um fuso porque java.util.Date era um contador de milissegundos mutável fingindo ser um calendário. Optional e java.time são as respostas. As duas são APIs pequenas, e as duas têm regras de uso bem estabelecidas, que este guia apresenta. O código usa Java 21.
Optional: um tipo para "talvez não exista"
Optional<T> é um contêiner que ou guarda um valor não nulo, ou está vazio. O propósito dele é restrito: como tipo de retorno, ele avisa na própria assinatura que o resultado pode faltar, e sua API empurra quem chama a tratar esse caso onde antes pularia a checagem 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");
Você cria um com Optional.of(value) (lança exceção se receber null), Optional.ofNullable(value) ou Optional.empty(). Depois, consome por meio dos combinadores, em vez de inspecioná-lo:
| Método | Uso |
|---|---|
map(fn) | Transforma o valor se ele existir; o resultado é Optional<R> |
flatMap(fn) | Como map quando fn já devolve um Optional, evitando Optional<Optional<R>> |
filter(pred) | Fica vazio se o predicado falhar |
orElse(v) | O valor ou um fallback; v é sempre avaliado |
orElseGet(supplier) | O valor ou um fallback calculado de forma preguiçosa |
orElseThrow() / orElseThrow(supplier) | O valor ou uma exceção (a forma sem argumentos lança NoSuchElementException) |
ifPresent(consumer) / ifPresentOrElse(consumer, runnable) | Efeitos colaterais para cada caso |
or(supplier) | Outro Optional se este estiver vazio (Java 9) |
stream() | Um stream de zero ou um elemento, útil dentro de flatMap (Java 9) |
A diferença entre orElse e orElseGet importa quando o fallback é caro: orElse(loadDefault()) chama loadDefault() toda vez, mesmo quando o valor está presente.
isPresent() seguido de get() é o padrão a desaprender. É uma checagem de null com mais caracteres, e get() lança NoSuchElementException quando está vazio, o que não é melhor que uma NPE. get() foi mantido por compatibilidade; orElseThrow() diz a mesma coisa, só que com honestidade.
Onde Optional não deve ser usado
- Campos.
Optionalnão éSerializable, custa uma alocação por objeto e o campo ainda pode sernull. Use um campo que aceite null e devolvaOptional.ofNullable(field)no getter se quiser que quem chama enxergue a ausência. - Parâmetros de método. Quem chama precisa envolver cada argumento. Ofereça uma sobrecarga ou aceite um valor que possa ser null.
- Coleções. Tanto
List<Optional<String>>quantoOptional<List<String>>são piores que uma lista simplesmente vazia. - Caminhos críticos de desempenho. É um objeto; num loop apertado, devolver
nullcom um contrato documentado, ou um valor sentinela primitivo, pode se justificar.OptionalInt,OptionalLongeOptionalDoubleexistem para resultados primitivos e são o queIntStream.max()devolve.
Um teste útil: se você não consegue expressar o código com map, flatMap, filter e um único orElse* terminal, provavelmente está usando Optional como um null disfarçado.
java.time: o modelo
As classes anteriores ao Java 8 tinham um único tipo, Date, para todos os conceitos, e ele era mutável, contava os meses a partir de zero e era UTC às escondidas. java.time dá a cada conceito seu próprio tipo imutável. Aprender qual usar é a maior parte do trabalho.
| Tipo | Representa | Exemplo | Use para |
|---|---|---|---|
Instant | Um ponto na linha do tempo UTC | 2026-09-07T10:15:30Z | Timestamps, logs, "quando aconteceu" |
LocalDate | Uma data de calendário, sem hora nem fuso | 2026-09-07 | Aniversários, datas de vencimento, dias úteis |
LocalTime | Uma hora de relógio, sem data nem fuso | 10:15:30 | Horário de funcionamento, alarmes |
LocalDateTime | Data e hora, sem fuso | 2026-09-07T10:15:30 | Valores que não têm fuso de propósito (uma reunião recorrente às 09:00) |
ZonedDateTime | Data, hora e um fuso regional com regras de horário de verão | 2026-09-07T10:15:30+02:00[Europe/Paris] | Exibir para um usuário, agendar numa região |
OffsetDateTime | Data, hora e um offset fixo em relação ao UTC | 2026-09-07T10:15:30+02:00 | Intercâmbio (JSON, bancos de dados) quando o offset é conhecido mas a região não |
Duration | Quantidade de tempo em segundos/nanos | PT1H30M | Timeouts, tempo decorrido |
Period | Quantidade de tempo em anos/meses/dias | P1M10D | Duração de assinaturas, idade |
ZoneId / ZoneOffset | Uma região (Europe/Paris) ou um offset fixo (+02:00) | Converter entre os tipos acima | |
Year, YearMonth, MonthDay, DayOfWeek, Month | Datas parciais | 2026-09 | Validade de cartão de crédito, relatórios |
A distinção mais importante: um LocalDateTime não é um momento. "2026-03-29T02:30" não existe em Paris (os relógios pulam esse horário) e "2026-10-25T02:30" acontece duas vezes. Só Instant, ZonedDateTime e OffsetDateTime identificam um momento único. Armazene e transmita Instant (ou OffsetDateTime quando a API exigir um offset); use ZonedDateTime quando o calendário local de uma pessoa importar; use LocalDateTime apenas quando a ausência de fuso for justamente o objetivo.
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 esses tipos são imutáveis: plusDays devolve um novo objeto e deixa o original intacto. Esquecer de usar o valor de retorno é um bug silencioso comum: date.plusDays(1); sozinho não faz nada.
Relógios e testabilidade
Instant.now(), LocalDate.now() e companhia leem o relógio do sistema, o que torna impossível testar de forma determinística o código que os usa. Todo now() tem uma sobrecarga que recebe um Clock; injete um (Clock.systemUTC() em produção, Clock.fixed(instant, zone) nos testes) e a lógica dependente de tempo vira uma função pura das suas entradas.
Aritmética: Duration versus Period
Duration mede tempo decorrido com exatidão; Period mede distância no calendário. Somar uma Duration de 24 horas a um ZonedDateTime atravessando uma mudança de horário de verão dá outra hora de relógio; somar um Period de um dia dá a mesma hora de relógio na data seguinte. Os dois estão corretos — para perguntas diferentes.
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) é a forma idiomática de perguntar "quantas unidades inteiras de distância há entre dois valores", e ele trunca em vez de arredondar. A aritmética de LocalDate se ajusta ao fim do mês: 31 de janeiro mais um mês é 28 (ou 29) de fevereiro, e somar mais um mês dá 28 de março, não 31 — já um Period de dois meses aplicado de uma vez dá 31 de março. Se o comportamento no fim do mês importa, calcule sempre a partir da data original.
Formatação e parse
DateTimeFormatter funciona nos dois sentidos e é imutável, então mantenha um num campo static final. Os formatadores ISO predefinidos cobrem os formatos de intercâmbio:
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"));
Letras de padrão que você precisa acertar: uuuu é o ano proléptico e é mais seguro que yyyy (ano da era, que precisa de uma era para não ser ambíguo no modo estrito); MM é mês e mm é minuto; HH é a hora em 24 horas, enquanto hh precisa de a para AM/PM; EEEE é o nome completo do dia. Sempre passe um Locale quando o padrão incluir nomes; caso contrário, a saída depende do locale padrão do servidor.
O parse é estrito por padrão com os formatadores ISO e usa ResolverStyle.SMART com padrões, o que aceita 31 de abril e o transforma em 30 de abril. Use .withResolverStyle(ResolverStyle.STRICT) (e uuuu, já que yyyy no modo estrito exige uma era) quando datas inválidas precisarem ser rejeitadas. Uma DateTimeParseException — unchecked — informa a posição em que a falha ocorreu.
Em APIs, emita ISO-8601 com offset ou Z (o perfil definido na RFC 3339). O Jackson serializa Instant como string ISO desde que o módulo jackson-datatype-jsr310 esteja registrado e WRITE_DATES_AS_TIMESTAMPS esteja desativado — o Spring Boot faz as duas coisas; só com o módulo você recebe um número de segundos com casas decimais, e sem ele as versões recentes do Jackson se recusam a serializar o tipo.
Interoperando com tipos legados e externos
Você vai encontrar java.util.Date em bibliotecas antigas, drivers JDBC e código baseado em Calendar. Converta na fronteira e use java.time em todo o resto:
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 e java.sql.Time são subclasses de java.util.Date com comportamento ainda mais estranho (java.sql.Date lança exceção nos métodos de hora do dia) e não deveriam aparecer no código da aplicação; o JDBC 4.2 e todos os drivers atuais aceitam LocalDate, LocalTime, LocalDateTime, OffsetDateTime e Instant via setObject/getObject. Para valores epoch em JSON ou em logs, o conversor de timestamp transforma milissegundos em datas legíveis durante a depuração.
Os dados de fuso horário vêm dentro do JDK (as regras tzdata) e são atualizados nas versões de patch do JDK. Governos mudam as regras de horário de verão com pouca antecedência; um JDK sem patch calcula horários locais errados para essas regiões.
Erros comuns
Optional.get() sem checagem. Lança NoSuchElementException; use orElseThrow com uma exceção significativa ou um combinador.
Optional em campos, parâmetros e coleções. Ferramenta errada; veja acima.
LocalDateTime para um timestamp. Ele não registra fuso, então o momento é ambíguo e as mudanças de horário de verão produzem valores impossíveis ou duplicados. Armazene Instant.
Ignorar o valor de retorno. date.plusDays(1); descarta o resultado porque todos os tipos de java.time são imutáveis.
Compartilhar SimpleDateFormat. Ele é mutável e não é thread-safe; duas threads corrompem a saída uma da outra. DateTimeFormatter pode ser compartilhado com segurança.
yyyy com parse estrito. O ano da era precisa de uma era; use uuuu.
Comparar ZonedDateTime com equals. Dois objetos para o mesmo instante em fusos diferentes não são equals. Use isEqual, isBefore, isAfter ou compare toInstant().
Supor que um dia tem 24 horas. Use Period para dias de calendário e Duration para horas decorridas; não multiplique por 86_400_000.
ZoneId.systemDefault() em código de servidor. Depende de onde a JVM roda. Passe explicitamente o fuso do usuário ou do negócio e rode os servidores em UTC.
Principais pontos
Optionalé um tipo de retorno que torna a ausência explícita; consuma-o commap,flatMap,filtereorElse*, nunca comisPresent/get, e mantenha-o fora de campos, parâmetros e coleções.- Só
Instant,ZonedDateTimeeOffsetDateTimerepresentam um momento único;LocalDateTimeé uma hora de relógio sem fuso e não serve para timestamps. - Armazene e troque
Instant(ou ISO-8601 com offset); converta paraZonedDateTimena ponta em que uma pessoa lê o valor. Durationé tempo decorrido exato;Periodé distância no calendário — os dois divergem, por design, nas mudanças de horário de verão e no fim do mês.- Todos os tipos de
java.timesão imutáveis e thread-safe; mantenha instâncias deDateTimeFormattercomo estáticas, passe umLocalee prefirauuuucom resolução estrita ao validar entradas. - Converta
Datelegado, milissegundos epoch e valores JDBC na fronteira, e injete umClockpara que o código dependente de tempo seja testável.
Fontes e leituras complementares
Perguntas frequentes
- Devo usar Optional como campo ou como parâmetro de método?
- Não. Optional foi projetado como tipo de retorno para métodos que podem, legitimamente, não ter resultado. Como campo, ele não é serializável, adiciona uma alocação e ainda exige checagem de null, já que a própria referência ao Optional pode ser null. Como parâmetro, obriga quem chama a envolver os valores; uma sobrecarga, ou um parâmetro que aceite null documentado com @Nullable, fica mais claro.
- Em qual tipo do java.time devo guardar um timestamp?
- Instant. Ele é um ponto na linha do tempo UTC, sem ambiguidade de fuso, e corresponde diretamente a TIMESTAMP WITH TIME ZONE no PostgreSQL ou a milissegundos epoch. Converta para ZonedDateTime só na ponta em que uma pessoa lê o valor. LocalDateTime não serve para timestamps porque não consegue dizer a qual momento se refere.
- Qual a diferença entre Duration e Period?
- Duration é uma quantidade exata de tempo em segundos e nanossegundos: 90 minutos são sempre 5.400 segundos. Period é uma quantidade de calendário em anos, meses e dias: um mês tem de 28 a 31 dias, e um dia pode ter 23 ou 25 horas numa mudança de horário de verão. Use Duration para aritmética baseada em tempo e Period para aritmética baseada em datas.
- DateTimeFormatter é thread-safe?
- Sim. Ao contrário do antigo SimpleDateFormat, que corrompe a saída quando compartilhado entre threads, DateTimeFormatter é imutável e pode ficar tranquilamente num campo static final. Crie-o uma única vez a partir de um padrão ou use as constantes ISO predefinidas.