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étodoUso
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. Optional não é Serializable, custa uma alocação por objeto e o campo ainda pode ser null. Use um campo que aceite null e devolva Optional.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>> quanto Optional<List<String>> são piores que uma lista simplesmente vazia.
  • Caminhos críticos de desempenho. É um objeto; num loop apertado, devolver null com um contrato documentado, ou um valor sentinela primitivo, pode se justificar. OptionalInt, OptionalLong e OptionalDouble existem para resultados primitivos e são o que IntStream.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.

TipoRepresentaExemploUse para
InstantUm ponto na linha do tempo UTC2026-09-07T10:15:30ZTimestamps, logs, "quando aconteceu"
LocalDateUma data de calendário, sem hora nem fuso2026-09-07Aniversários, datas de vencimento, dias úteis
LocalTimeUma hora de relógio, sem data nem fuso10:15:30Horário de funcionamento, alarmes
LocalDateTimeData e hora, sem fuso2026-09-07T10:15:30Valores que não têm fuso de propósito (uma reunião recorrente às 09:00)
ZonedDateTimeData, hora e um fuso regional com regras de horário de verão2026-09-07T10:15:30+02:00[Europe/Paris]Exibir para um usuário, agendar numa região
OffsetDateTimeData, hora e um offset fixo em relação ao UTC2026-09-07T10:15:30+02:00Intercâmbio (JSON, bancos de dados) quando o offset é conhecido mas a região não
DurationQuantidade de tempo em segundos/nanosPT1H30MTimeouts, tempo decorrido
PeriodQuantidade de tempo em anos/meses/diasP1M10DDuração de assinaturas, idade
ZoneId / ZoneOffsetUma região (Europe/Paris) ou um offset fixo (+02:00)Converter entre os tipos acima
Year, YearMonth, MonthDay, DayOfWeek, MonthDatas parciais2026-09Validade 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 com map, flatMap, filter e orElse*, nunca com isPresent/get, e mantenha-o fora de campos, parâmetros e coleções.
  • Só Instant, ZonedDateTime e OffsetDateTime representam 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 para ZonedDateTime na 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.time são imutáveis e thread-safe; mantenha instâncias de DateTimeFormatter como estáticas, passe um Locale e prefira uuuu com resolução estrita ao validar entradas.
  • Converta Date legado, milissegundos epoch e valores JDBC na fronteira, e injete um Clock para 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.