Blog

Optional, java.time e como conviver com null em Java

O Java deixa qualquer referência ser null, então um valor ausente pode derrubar o código longe de onde sumiu. Objects e Optional deixam a ausência visível, e java.time faz você dizer de qual momento, em qual cidade, está falando.

Uma referência Java sempre pode ser null, e nada no tipo avisa quando esperar isso. Este post cobre as ferramentas que o JDK dá para lidar com isso: os helpers de Objects, Optional para um resultado que pode não existir, e os lugares onde Optional piora o código. Depois ele passa para java.time, onde um detalhe que falta causa o mesmo tipo de bug. Uma data, um horário de relógio e um momento na linha do tempo são coisas diferentes, e confundir uma com a outra gera bugs que aparecem duas vezes por ano.

Todo programa abaixo rodou no Java 25, e a saída foi colada da execução. Para rodar um você mesmo, salve como Main.java e rode java Main.java. Nenhum deles lê o relógio real nem o fuso horário da sua máquina, então você vai ter a mesma saída que nós.

null quer dizer “nenhum objeto”, e o crash vem depois

Um NullPointerException é lançado quando o código chama um método ou lê um campo por uma referência que é null. O problema é que o null costuma chegar em silêncio, e o crash acontece algumas linhas depois. Map.get retorna null para uma chave que não existe:

Map<String, String> settings = new HashMap<>();

void main() {
    settings.put("theme", "dark");
    IO.println(settings.get("theme").toUpperCase());
    IO.println(settings.get("font").toUpperCase());
}

Ele imprime e para:

DARK
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.toUpperCase()" because the return value of "java.util.Map.get(Object)" is null

Essa é uma mensagem útil de NullPointerException, que a parte sobre valores e referências mostrou pela primeira vez. Ela cita a expressão exata que era null. Mas não consegue dizer por que a chave faltava. Esse é o problema real do null: ele mostra onde o crash aconteceu, não onde o valor se perdeu.

Verificando null com Objects

A classe java.util.Objects tem pequenos métodos estáticos que tratam null num só lugar, em vez de espalhar verificações if (x != null). Aqui estão os três que você mais vai usar:

record Account(String owner, String nickname) {
    Account {
        Objects.requireNonNull(owner, "owner is required");
        nickname = Objects.requireNonNullElse(nickname, owner);
    }
}

void main() {
    IO.println(new Account("Ana", "annie"));
    IO.println(new Account("Ben", null));

    try {
        new Account(null, "ghost");
    } catch (NullPointerException e) {
        IO.println("rejected: " + e.getMessage());
    }

    String typed = null;
    String stored = "Ana";
    IO.println(Objects.equals(typed, stored));
    IO.println(Objects.equals(null, null));
}

Ele imprime:

Account[owner=Ana, nickname=annie]
Account[owner=Ben, nickname=Ben]
rejected: owner is required
false
true
  • requireNonNull(value, message) lança na hora, no construtor, em vez de deixar um owner null viajar até alguém chamar um método nele. A parte sobre exceções explica por que a mensagem importa.
  • requireNonNullElse(value, fallback) retorna o valor, ou o fallback quando o valor é null. Ben não tinha apelido, então ficou com o nome. O próprio fallback não pode ser null: requireNonNullElse(null, null) lança.
  • Objects.equals(a, b) é true quando os dois são null, false quando só um é, e fora isso chama a.equals(b). Escrever typed.equals(stored) teria lançado exceção, porque typed é null.

O sistema de tipos do Java não tem como dizer “esta String nunca é null“. Todo tipo de referência aceita null, e o javac não verifica. Bibliotecas de terceiros como a JSpecify adicionam anotações como @Nullable, e ferramentas no seu editor ou no build leem essas anotações e avisam você. Esta série usa só o JDK, então não usa essas anotações, mas você vai encontrá-las em codebases reais.

Optional: um tipo de retorno para “talvez sem resultado”

Optional<T> é uma caixinha que guarda um valor ou nada. Um método que retorna Optional<User> diz na assinatura que pode não haver usuário, então quem chama não consegue esquecer esse caso, como esquece um null.

record User(String name, String email) {}

List<User> users = List.of(
        new User("ana", "ana@example.com"),
        new User("ben", null));

Optional<User> findUser(String name) {
    return users.stream()
            .filter(u -> u.name().equals(name))
            .findFirst();
}

void main() {
    IO.println(findUser("ana"));
    IO.println(findUser("zoe"));
    IO.println(findUser("zoe").isPresent());

    Optional<String> anaEmail = Optional.ofNullable(findUser("ana").orElseThrow().email());
    Optional<String> benEmail = Optional.ofNullable(findUser("ben").orElseThrow().email());
    IO.println(anaEmail);
    IO.println(benEmail);
    IO.println(Optional.empty().equals(benEmail));
}

Ele imprime:

Optional[User[name=ana, email=ana@example.com]]
Optional.empty
false
Optional[ana@example.com]
Optional.empty
true

findFirst já retorna um Optional, como a parte sobre streams mencionou. Há três jeitos de criar um você mesmo:

  • Optional.of(value) quando você sabe que o valor não é null.
  • Optional.ofNullable(value) quando ele pode ser. Um null vira um Optional vazio, como aconteceu com o email que faltava para Ben.
  • Optional.empty() para nada.

orElseThrow() sem argumentos retorna o valor, ou lança exceção se não houver um. Usamos com Ana e Ben porque sabemos que eles existem.

orElse roda o argumento mesmo quando ele não é necessário

orElse e orElseGet dão um fallback para um Optional vazio, e parecem intercambiáveis. Não são, porque o Java avalia os argumentos de um método antes de chamar o método:

String loadDefault() {
    IO.println("  loading the default name...");
    return "guest";
}

void main() {
    Optional<String> name = Optional.of("ana");

    IO.println("orElse:");
    IO.println(name.orElse(loadDefault()));

    IO.println("orElseGet:");
    IO.println(name.orElseGet(this::loadDefault));
}

Ele imprime:

orElse:
  loading the default name...
ana
orElseGet:
ana

O Optional tinha valor nas duas vezes, então nenhum fallback foi usado. Mas orElse(loadDefault()) chamou loadDefault() antes, para ter um argumento a passar. orElseGet recebe um Supplier, uma função que ele só chama quando o Optional está vazio. Se o fallback é uma constante como "guest", orElse serve. Se ele lê um arquivo, consulta um banco de dados ou monta algo caro, use orElseGet.

Pedindo o valor a um Optional vazio

get() retorna o valor dentro de um Optional, e lança exceção quando não há valor. Isso o deixa tão seguro quanto uma verificação de null que você esqueceu:

void main() {
    Optional<String> email = Optional.empty();

    try {
        email.orElseThrow(() -> new IllegalStateException("ben has no email on file"));
    } catch (IllegalStateException e) {
        IO.println("caught: " + e.getMessage());
    }

    IO.println(email.get());
}

Ele imprime e para:

caught: ben has no email on file
Exception in thread "main" java.util.NoSuchElementException: No value present

orElseThrow(supplier) deixa você lançar uma exceção que explica o que faltava. get() e orElseThrow() lançam NoSuchElementException com a mensagem No value present. Os dois fazem exatamente a mesma coisa, mas orElseThrow() diz no nome o que faz, e é por isso que foi adicionado no Java 10. Prefira ele a get().

Optional.of(null) lança exceção

Optional.of recusa um null, e a exceção vem sem mensagem nenhuma:

String nickname(String name) {
    return name.equals("ana") ? "annie" : null;
}

void main() {
    IO.println(Optional.ofNullable(nickname("ben")));
    IO.println(Optional.of(nickname("ben")));
}

Ele imprime e para:

Optional.empty
Exception in thread "main" java.lang.NullPointerException

Não há mensagem útil, porque Optional.of verifica com Objects.requireNonNull dentro do JDK em vez de chamar um método no null. O primeiro frame do stack trace cita Objects.requireNonNull. Se você vir um NPE sem mensagem ali, a correção costuma ser ofNullable.

Transformando um Optional sem desembrulhar

map, filter e flatMap trabalham com o valor dentro de um Optional e pulam o trabalho quando ele está vazio, então uma cadeia de etapas não precisa de nenhum if:

record User(String name, String email) {}

Map<String, User> users = new HashMap<>();
Map<String, String> cities = new HashMap<>();

Optional<User> findUser(String name) {
    return Optional.ofNullable(users.get(name));
}

Optional<String> cityOf(User user) {
    return Optional.ofNullable(cities.get(user.name()));
}

void main() {
    users.put("ana", new User("ana", "ana@example.pt"));
    users.put("ben", new User("ben", null));
    cities.put("ana", "Lisbon");

    IO.println(findUser("ana").map(User::email));
    IO.println(findUser("ben").map(User::email));
    IO.println(findUser("zoe").map(User::email));

    IO.println(findUser("ana").map(User::email).filter(e -> e.endsWith(".com")));

    IO.println(findUser("ana").map(this::cityOf));
    IO.println(findUser("ana").flatMap(this::cityOf));
    IO.println(findUser("ben").flatMap(this::cityOf));
}

Ele imprime:

Optional[ana@example.pt]
Optional.empty
Optional.empty
Optional.empty
Optional[Optional[Lisbon]]
Optional[Lisbon]
Optional.empty
  • map aplica uma função ao valor. Se a função retorna null, como o email() de Ben, você recebe um Optional vazio, não um Optional guardando null.
  • filter mantém o valor só se o teste passar. O email de Ana termina em .pt, então o resultado é vazio.
  • flatMap é para uma função que já retorna um Optional. map(this::cityOf) embrulhou um Optional dentro de outro. flatMap não faz isso.

Mais três métodos completam o conjunto:

record User(String name) {}

Map<String, User> users = new HashMap<>();

Optional<User> findUser(String name) {
    return Optional.ofNullable(users.get(name));
}

void main() {
    users.put("ana", new User("ana"));
    users.put("ben", new User("ben"));

    findUser("ana").ifPresentOrElse(
            u -> IO.println("hello, " + u.name()),
            () -> IO.println("no such user"));
    findUser("zoe").ifPresentOrElse(
            u -> IO.println("hello, " + u.name()),
            () -> IO.println("no such user"));

    IO.println(findUser("zoe").or(() -> findUser("ana")));

    List<String> found = Stream.of("ana", "zoe", "ben")
            .map(this::findUser)
            .flatMap(Optional::stream)
            .map(User::name)
            .toList();
    IO.println(found);
}

Ele imprime:

hello, ana
no such user
Optional[User[name=ana]]
[ana, ben]
  • ifPresentOrElse roda uma ação quando há valor e outra quando não há.
  • or dá um fallback que também é um Optional, o que combina com uma segunda busca que também pode falhar. orElse obrigaria você a escolher um valor simples.
  • stream() transforma um valor num stream de um elemento e a ausência num stream vazio. Com flatMap, ele descarta as buscas que falharam numa lista de buscas, então Zoe simplesmente não aparece.

Onde Optional não cabe

Optional foi projetado como tipo de retorno, e piora o código na maioria dos outros lugares. Um parâmetro do tipo Optional é o caso mais claro, porque quem chama ainda pode passar null:

String greet(Optional<String> name) {
    return "Hello, " + name.orElse("guest");
}

void main() {
    IO.println(greet(Optional.of("Ana")));
    IO.println(greet(Optional.empty()));
    IO.println(greet(null));
}

Ele imprime e para:

Hello, Ana
Hello, guest
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.util.Optional.orElse(Object)" because "<parameter1>" is null

Agora o parâmetro tem três estados em vez de dois, e todo chamador precisa embrulhar o argumento. Dois métodos simples, greet(String name) e greet(), dizem a mesma coisa com mais clareza. A mensagem diz <parameter1> em vez de name porque a classe foi compilada sem informação de debug. Compilamos de novo com javac -g, e a mensagem citou name.

Os outros lugares a evitar:

  • Campos. Um campo pode guardar null e sua classe controla isso, então verifique no construtor. Optional também não é Serializable: Serializable.class.isAssignableFrom(Optional.class) é false.
  • Coleções de Optional. Uma List<Optional<User>> obriga todo leitor a desembrulhar cada elemento. Deixe os vazios de fora, como flatMap(Optional::stream) fez acima.
  • Optional<List<T>>. Uma lista já sabe dizer “nada”: ela fica vazia. Retorne List.of() e o laço for de todo chamador funciona, sem caso especial.

Para primitivos, OptionalInt, OptionalLong e OptionalDouble evitam boxing. A parte sobre streams mostrou OptionalDouble vindo de average():

void main() {
    OptionalInt best = IntStream.of(72, 95, 88).max();
    IO.println(best);
    IO.println(best.getAsInt());

    OptionalInt none = IntStream.empty().max();
    IO.println(none);
    IO.println(none.orElse(0));
}

Ele imprime:

OptionalInt[95]
95
OptionalInt.empty
0

Eles têm menos métodos que Optional: nada de map, filter ou flatMap. O getter é getAsInt(), não get().

Os tipos de java.time: data, hora e onde

O pacote java.time, adicionado no Java 8, tem um tipo separado para cada pergunta que você pode fazer sobre tempo. A mesma data e o mesmo horário de relógio podem ser dois momentos diferentes, dependendo da cidade:

void main() {
    var date = LocalDate.of(2026, 3, 29);
    var time = LocalTime.of(9, 30);
    var dateTime = LocalDateTime.of(date, time);

    var lisbon = ZonedDateTime.of(dateTime, ZoneId.of("Europe/Lisbon"));
    var tokyo = ZonedDateTime.of(dateTime, ZoneId.of("Asia/Tokyo"));

    IO.println(date + " is a " + date.getDayOfWeek());
    IO.println(time);
    IO.println(dateTime);
    IO.println(lisbon);
    IO.println(tokyo);
    IO.println(lisbon.toInstant());
    IO.println(tokyo.toInstant());
}

Ele imprime:

2026-03-29 is a SUNDAY
09:30
2026-03-29T09:30
2026-03-29T09:30+01:00[Europe/Lisbon]
2026-03-29T09:30+09:00[Asia/Tokyo]
2026-03-29T08:30:00Z
2026-03-29T00:30:00Z
  • LocalDate é uma data sem hora: um aniversário.
  • LocalTime é uma hora sem data: “a loja abre às 09:30”.
  • LocalDateTime é as duas coisas, sem fuso.
  • ZonedDateTime acrescenta um fuso como Europe/Lisbon, mais o offset em relação ao UTC que valia naquele momento, aqui +01:00.
  • Instant é um ponto na linha do tempo em UTC, impresso com um Z. Dois computadores em continentes diferentes concordam sobre um Instant.

Os dois valores com fuso mostram 09:30 no relógio, mas estão a oito horas de distância. Todo ZoneId neste post está escrito por extenso. ZoneId.systemDefault() retorna o fuso que estiver configurado na máquina, então o código que usa isso dá respostas diferentes num laptop e num servidor.

Explicado como se você tivesse dez anos

Um LocalDateTime é uma foto de um relógio de parede. A foto mostra 09:30 de domingo, 29 de março, mas não faz ideia de em que cidade o relógio estava pendurado. Se você perguntar “isso foi antes ou depois do almoço em Tóquio?”, a foto não sabe responder.

Um ZonedDateTime é a mesma foto com o nome da cidade escrito no verso: “Lisboa”. Agora qualquer pessoa, em qualquer lugar, consegue descobrir que horas eram na cidade dela quando a foto foi tirada.

A versão precisa

Um LocalDateTime é um ano, mês, dia, hora, minuto, segundo e nanossegundo. Ele não identifica um momento, por isso o método toInstant dele exige que você passe um offset. Um ZonedDateTime é um LocalDateTime, um ZoneId e um ZoneOffset. O fuso guarda as regras, vindas do banco de dados tz que acompanha o JDK, sobre qual offset vale em qual momento. toInstant() subtrai o offset e dá o UTC.

Onde a analogia falha: o nome da cidade nem sempre basta. Alguns horários de relógio acontecem duas vezes numa cidade, quando os relógios atrasam no outono, e alguns nunca acontecem, quando adiantam na primavera. É por isso que um ZonedDateTime guarda o offset além do fuso. A seção sobre horário de verão mostra os dois casos.

Objetos de java.time nunca mudam

Todo tipo de java.time é imutável, então plusDays retorna um objeto novo e deixa o original como estava. Ignorar o valor de retorno é o bug clássico, e o javac não avisa:

void main() {
    var due = LocalDate.of(2026, 3, 29);

    due.plusDays(14);
    IO.println("ignored the result: " + due);

    due = due.plusDays(14);
    IO.println("kept the result:    " + due);
}

Ele imprime:

ignored the result: 2026-03-29
kept the result:    2026-04-12

O primeiro plusDays(14) criou uma data nova e jogou fora. Se você está acostumado com o antigo Calendar.add, que mudava o objeto, isso parece certo e não faz nada. A vantagem da imutabilidade é que você pode compartilhar uma data entre threads, ou usá-la como chave de map, sem ninguém mudá-la por baixo.

Somando meses: o fim do mês se move

Somar um mês a uma data mantém o dia do mês quando dá, e usa o último dia válido quando não dá. É no dia 31 de janeiro que isso aparece:

void main() {
    var jan31 = LocalDate.of(2026, 1, 31);

    IO.println(jan31.plusMonths(1));
    IO.println(LocalDate.of(2028, 1, 31).plusMonths(1));
    IO.println(jan31.plusMonths(1).plusMonths(1));
    IO.println(jan31.plusMonths(2));
    IO.println(LocalDate.of(2026, 3, 31).minusMonths(1));
}

Ele imprime:

2026-02-28
2028-02-29
2026-03-28
2026-03-31
2026-02-28

2026 não é ano bissexto, então fevereiro termina no dia 28. 2028 é, então termina no dia 29. A terceira e a quarta linhas são a surpresa: um mês mais um mês não é igual a dois meses. Depois do primeiro passo, o 31 já virou 28, e nada lembra dele. Se você cobra no último dia de cada mês, calcule cada data a partir da data inicial, como plusMonths(2) faz, não a partir da anterior.

Duration e Period

Duration mede tempo em segundos e nanossegundos, e Period mede em anos, meses e dias. Parecem a mesma ideia, mas um mês não tem um número fixo de segundos:

void main() {
    var start = LocalDate.of(2026, 1, 31);
    var end = LocalDate.of(2026, 3, 29);
    IO.println(Period.between(start, end));
    IO.println(ChronoUnit.DAYS.between(start, end));

    var boarding = LocalDateTime.of(2026, 3, 28, 22, 45);
    var landing = LocalDateTime.of(2026, 3, 29, 1, 0);
    IO.println(Duration.between(boarding, landing));
    IO.println(Duration.ofMinutes(135).toHours());
}

Ele imprime:

P1M29D
57
PT2H15M
2

Os dois imprimem no formato ISO 8601. P1M29D é um mês e 29 dias. PT2H15M é duas horas e quinze minutos, onde T separa a parte da data da parte da hora. ChronoUnit.DAYS.between dá uma contagem simples quando é isso que você precisa.

A diferença importa mais quando há um fuso envolvido, porque um dia no calendário nem sempre tem 24 horas.

Fazendo parse e formatando datas

Todo tipo de java.time imprime no formato ISO 8601, e parse lê esse mesmo formato de volta. Para qualquer outro layout, você monta um DateTimeFormatter a partir de um padrão:

void main() {
    var date = LocalDate.parse("2026-03-29");
    var meeting = LocalDateTime.parse("2026-03-29T14:05");
    IO.println(date.plusDays(1));
    IO.println(meeting);

    var pretty = DateTimeFormatter.ofPattern("EEEE d MMMM uuuu, HH:mm", Locale.US);
    IO.println(meeting.format(pretty));

    var european = DateTimeFormatter.ofPattern("dd/MM/uuuu");
    IO.println(LocalDate.parse("05/04/2026", european));
    IO.println(date.format(european));
}

Ele imprime:

2026-03-30
2026-03-29T14:05
Sunday 29 March 2026, 14:05
2026-04-05
29/03/2026

EEEE é o nome completo do dia e MMMM o nome completo do mês. Essas palavras dependem de um idioma, por isso o formatter pretty cita Locale.US. Sem isso, o Java usa o locale da máquina, e o mesmo programa imprime domingo num computador configurado em português. O padrão european só tem números, então não precisa de locale. 05/04/2026 foi lido como 5 de abril, porque o padrão diz que o dia vem primeiro.

Um texto que não se encaixa lança DateTimeParseException:

void main() {
    try {
        LocalDate.parse("29/03/2026");
    } catch (DateTimeParseException e) {
        IO.println("caught: " + e.getMessage());
    }

    IO.println(LocalDate.parse("2026-02-30"));
}

Ele imprime e para:

caught: Text '29/03/2026' could not be parsed at index 0
Exception in thread "main" java.time.format.DateTimeParseException: Text '2026-02-30' could not be parsed: Invalid date 'FEBRUARY 30'

O primeiro texto tem o formato errado, e a mensagem aponta para o índice 0, onde o parser ISO esperava um ano de quatro dígitos. O segundo tem o formato certo e uma data impossível. parse verifica as duas coisas, então você não consegue enfiar um 30 de fevereiro nos seus dados.

Horário de verão: 1 dia não é 24 horas

No dia em que os relógios adiantam, um dia no calendário tem 23 horas, e java.time obriga você a escolher qual dos dois você quis dizer. Em Lisboa, em 2026, isso cai no domingo, 29 de março, quando 01:00 vira 02:00:

void main() {
    var lisbon = ZoneId.of("Europe/Lisbon");
    var saturdayNoon = ZonedDateTime.of(2026, 3, 28, 12, 0, 0, 0, lisbon);

    IO.println("start:              " + saturdayNoon);
    IO.println("plusDays(1):        " + saturdayNoon.plusDays(1));
    IO.println("plusHours(24):      " + saturdayNoon.plusHours(24));
    IO.println("Period.ofDays(1):   " + saturdayNoon.plus(Period.ofDays(1)));
    IO.println("Duration.ofDays(1): " + saturdayNoon.plus(Duration.ofDays(1)));

    var hours = Duration.between(saturdayNoon, saturdayNoon.plusDays(1)).toHours();
    IO.println("hours from noon to noon: " + hours);
}

Ele imprime:

start:              2026-03-28T12:00Z[Europe/Lisbon]
plusDays(1):        2026-03-29T12:00+01:00[Europe/Lisbon]
plusHours(24):      2026-03-29T13:00+01:00[Europe/Lisbon]
Period.ofDays(1):   2026-03-29T12:00+01:00[Europe/Lisbon]
Duration.ofDays(1): 2026-03-29T13:00+01:00[Europe/Lisbon]
hours from noon to noon: 23

A primeira linha mostra uma peculiaridade: o offset de inverno de Lisboa é zero, e um offset zero é impresso como Z, não +00:00.

plusDays(1) trabalha no calendário. Ele mantém o horário do relógio, meio-dia, e deixa o offset mudar. plusHours(24) trabalha na linha do tempo. Ele soma exatamente 24 horas de tempo real, o que cai às 13:00 no novo offset.

As duas últimas linhas são a armadilha. Duration.ofDays(1) parece um dia, mas um Duration é um número de segundos, então são 24 horas. Period.ofDays(1) é um dia de calendário. Use Period ou plusDays para “amanhã no mesmo horário”, e Duration ou plusHours para “daqui a exatamente 24 horas”.

Europe/Lisbon, tempo real indo para a direita plusDays(1): dom 12:00 +01:00, 23 h sáb 12:00 +00:00 dom 01:00 vira 02:00 plusHours(24): dom 13:00 +01:00, 24 h

Começando ao meio-dia de sábado em Lisboa, os relógios pulam de 01:00 para 02:00 no domingo. plusDays(1) mantém o horário do relógio, então cai no domingo às 12:00, só 23 horas reais depois. plusHours(24) soma 24 horas reais, então cai no domingo às 13:00.

Um horário que nunca aconteceu, e um que aconteceu duas vezes

Em 29 de março de 2026, nenhum relógio de Lisboa mostrou 01:30. Em 25 de outubro de 2026, quando os relógios atrasam, 01:30 aconteceu duas vezes. O Java precisa escolher alguma coisa nos dois casos:

void main() {
    var lisbon = ZoneId.of("Europe/Lisbon");
    var rules = lisbon.getRules();

    var inGap = LocalDateTime.of(2026, 3, 29, 1, 30);
    IO.println("offsets for " + inGap + ": " + rules.getValidOffsets(inGap));
    IO.println(ZonedDateTime.of(inGap, lisbon));

    var inOverlap = LocalDateTime.of(2026, 10, 25, 1, 30);
    IO.println("offsets for " + inOverlap + ": " + rules.getValidOffsets(inOverlap));
    var first = ZonedDateTime.of(inOverlap, lisbon);
    IO.println(first);
    IO.println(first.withLaterOffsetAtOverlap());
}

Ele imprime:

offsets for 2026-03-29T01:30: []
2026-03-29T02:30+01:00[Europe/Lisbon]
offsets for 2026-10-25T01:30: [+01:00, Z]
2026-10-25T01:30+01:00[Europe/Lisbon]
2026-10-25T01:30Z[Europe/Lisbon]
  • Na lacuna, nenhum offset é válido, então o Java adianta o horário pelo tamanho da lacuna. 01:30 virou 02:30. Ele não lança exceção, o que quer dizer que um alarme de 01:30 toca em silêncio às 02:30.
  • Na sobreposição, dois offsets são válidos, e o Java escolhe o primeiro, o offset de verão +01:00. withLaterOffsetAtOverlap() dá o segundo 01:30, uma hora depois em tempo real.

Se um job agendado precisa rodar exatamente uma vez, guarde o horário dele como Instant, ou verifique getValidOffsets quando transformar um horário local num horário com fuso.

Se você encontrar Date e Calendar, converta na fronteira

java.util.Date e Calendar são as classes de data que o Java tinha antes do Java 8. Elas são mutáveis, contam os meses a partir de zero, e Date.toString() usa em silêncio o fuso horário da máquina. Você ainda vai encontrá-las em bibliotecas antigas. Converta para java.time assim que elas entrarem no seu código, e converta de volta só quando entregar um valor para essa biblioteca:

Date lastLoginFromOldLibrary() {
    return new Date(1774779330000L);
}

void main() {
    Instant lastLogin = lastLoginFromOldLibrary().toInstant();
    IO.println(lastLogin);
    IO.println(lastLogin.atZone(ZoneId.of("Europe/Lisbon")));

    Date backForTheLibrary = Date.from(lastLogin);
    IO.println(backForTheLibrary.getTime());
}

Ele imprime:

2026-03-29T10:15:30Z
2026-03-29T11:15:30+01:00[Europe/Lisbon]
1774779330000

toInstant() e Date.from são a ponte nos dois sentidos. Um Date é uma contagem de milissegundos desde 1970, então transformá-lo num Instant não perde nada. No outro sentido, tudo abaixo de um milissegundo se perde: testamos um Instant terminado em .123456789 e recebemos .123 de volta. Para um GregorianCalendar, toZonedDateTime() faz o mesmo trabalho e mantém o fuso dele.

Clock: código que você consegue testar

Um método que chama LocalDate.now() dá uma resposta diferente a cada dia, o que dificulta o teste. Passe um Clock para ele, e quem chama decide o que “agora” quer dizer:

record Subscription(String owner, LocalDate lastDay) {}

boolean isExpired(Subscription subscription, Clock clock) {
    return LocalDate.now(clock).isAfter(subscription.lastDay());
}

void main() {
    var ana = new Subscription("ana", LocalDate.of(2026, 3, 29));
    var lisbon = ZoneId.of("Europe/Lisbon");

    var lateSunday = Clock.fixed(Instant.parse("2026-03-29T22:30:00Z"), lisbon);
    var justAfter = Clock.fixed(Instant.parse("2026-03-29T23:30:00Z"), lisbon);

    IO.println(LocalDate.now(lateSunday) + " expired: " + isExpired(ana, lateSunday));
    IO.println(LocalDate.now(justAfter) + " expired: " + isExpired(ana, justAfter));
}

Ele imprime:

2026-03-29 expired: false
2026-03-30 expired: true

Clock.fixed retorna um relógio parado num Instant, num fuso. 22:30 UTC é 23:30 em Lisboa, ainda domingo. Uma hora depois é 00:30 de segunda-feira em Lisboa, então a assinatura expirou, mesmo que ainda seja domingo em UTC. Um teste consegue verificar os dois lados da meia-noite sem esperar a meia-noite chegar.

Em produção, você passaria Clock.system(ZoneId.of("Europe/Lisbon")), ou o fuso dos seus usuários. Todo método now de java.time tem uma sobrecarga que recebe um Clock, então um único parâmetro cobre todos eles.

O que lembrar

  • Qualquer referência pode ser null, e o javac não verifica. Use Objects.requireNonNull na entrada, requireNonNullElse para valores padrão e Objects.equals para comparar valores que podem ser null.
  • Retorne Optional quando um método pode não ter resultado. Não use em campos, parâmetros ou coleções, e retorne uma lista vazia em vez de Optional<List>.
  • orElse avalia o fallback toda vez, e orElseGet só quando precisa. Prefira orElseThrow() a get(), e use ofNullable quando um valor pode ser null.
  • Objetos de java.time são imutáveis. plusDays retorna uma data nova, então guarde o resultado.
  • LocalDateTime não tem fuso e não é um momento. Use ZonedDateTime com um ZoneId explícito, ou Instant, quando o momento importa.
  • Numa mudança de horário de verão, plusDays(1) e Period mantêm o horário do relógio, enquanto plusHours(24) e Duration somam horas reais.
  • Passe um Clock para o código que precisa de “agora”, e teste com Clock.fixed.

Diga qual fuso horário você quer, toda vez, e deixe o tipo dizer se um valor pode faltar.

Quanto este post te ajudou?

Clique em um coração para avaliar!

Média das avaliações 0 / 5. Total de votos: 0

Nenhum voto até agora. Seja o primeiro a avaliar este post.