PrepZone Logo
PrepZone

Optional and the Date-Time API

Modelling absence without null, and the immutable date types that replaced Date and Calendar.

Why this matters

  • Optional used as a replacement for every null is misuse; used as a return type it genuinely removes a class of bug. The distinction is routinely asked about.
  • Date and Calendar are mutable, zero-indexed in places, and not thread-safe. Code still using them is a source of recurring bugs.
  • Time zones and daylight saving are where most date arithmetic goes wrong, and the new API makes the correct choice explicit.

Optional

Java
// The signature now says that a user may not be found
public Optional<User> findByEmail(String email) {
    return Optional.ofNullable(repository.lookup(email));
}
Java
Optional.of(value);              // throws if value is null — use when null is a bug
Optional.ofNullable(value);       // empty if value is null — the usual factory
Optional.empty();
Optional<User>May or may not hold a value
PresentFunction applied
EmptyFunction skipped
A plain valueNo null, no exception
Chain map and orElse so the empty case is handled by the pipeline. Calling get() without checking just moves the null problem to a different exception.

Reading the value

Java
Optional<User> found = findByEmail(email);

// A default
User user = found.orElse(User.anonymous());

// A default computed only if needed
User lazy = found.orElseGet(() -> expensiveDefault());

// Fail if absent
User required = found.orElseThrow(() -> new UserNotFoundException(email));
User alsoRequired = found.orElseThrow();      // NoSuchElementException, Java 10+

// Act only if present
found.ifPresent(user -> audit.record(user));
found.ifPresentOrElse(user -> audit.record(user), () -> audit.missing(email));

// Transform without unwrapping
Optional<String> name = found.map(User::name);
Optional<Address> address = found.flatMap(User::address);   // when the mapper returns an Optional
Optional<User> active = found.filter(User::isActive);

// Fall back to another Optional, Java 9+
Optional<User> either = found.or(() -> findByUsername(email));

// Treat it as a stream, Java 9+
List<User> users = emails.stream().map(this::findByEmail).flatMap(Optional::stream).toList();

How to use it, and how not to

AspectAppropriateMisuse
A method return typeSignals that absence is expectedA field — adds a wrapper object per instance
Chaining with map and filterAvoids nested null checksA method parameter — now three cases instead of two
orElseThrow for a required valueClear and explicitget() without checking — worse than a null check
ofNullable around a legacy APIConverts null at the boundaryOptional<List> — return an empty list instead
Replacing a null returnThe intended useReplacing every null in your codebase
  • A method return type

    AppropriateSignals that absence is expected
    MisuseA field — adds a wrapper object per instance
  • Chaining with map and filter

    AppropriateAvoids nested null checks
    MisuseA method parameter — now three cases instead of two
  • orElseThrow for a required value

    AppropriateClear and explicit
    Misuseget() without checking — worse than a null check
  • ofNullable around a legacy API

    AppropriateConverts null at the boundary
    MisuseOptional<List> — return an empty list instead
  • Replacing a null return

    AppropriateThe intended use
    MisuseReplacing every null in your codebase

Optional is for return types. Everywhere else it usually adds a case rather than removing one.

Java
// The pattern Optional actually replaces
String city = null;
if (user != null) {
    Address address = user.getAddress();
    if (address != null) {
        city = address.getCity();
    }
}

// The same intent, flattened
String city = Optional.ofNullable(user)
        .map(User::getAddress)
        .map(Address::getCity)
        .orElse("unknown");

The date-time API

Java 8 replaced Date and Calendar with a set of types that each model one concept.

Pick the type that matches the concept

  • LocalDate — a date with no time and no zone. A birthday, an invoice date.
  • LocalTime — a time with no date. An opening hour.
  • LocalDateTime — both, still with no zone. A wall-clock appointment.
  • ZonedDateTime — a date and time in a specific zone, with daylight-saving rules applied.
  • Instant — a point on the global timeline in UTC. What you store in a database.
  • Duration — a time-based amount: seconds, minutes, hours.
  • Period — a date-based amount: years, months, days.
Java
LocalDate today = LocalDate.now();
LocalDate launch = LocalDate.of(2026, Month.MARCH, 15);
LocalTime opening = LocalTime.of(9, 30);
LocalDateTime meeting = LocalDateTime.of(launch, opening);
ZonedDateTime inTokyo = meeting.atZone(ZoneId.of("Asia/Tokyo"));
Instant now = Instant.now();

Everything is immutable

Java
LocalDate date = LocalDate.of(2026, 1, 31);

LocalDate later = date.plusMonths(1);        // 2026-02-28 — clamped, not invalid
LocalDate shifted = date.plusDays(10).minusWeeks(2);
LocalDate adjusted = date.withDayOfMonth(1);

System.out.println(date);                     // 2026-01-31 — unchanged

Every method returns a new object, so these types are thread-safe and safe to share. The clamping in the first line is worth noting: adding a month to 31 January gives the last valid day of February rather than throwing.

Java
// Comparisons and queries
date.isBefore(later);
date.isAfter(launch);
date.getDayOfWeek();                          // SATURDAY
date.isLeapYear();
date.lengthOfMonth();

// Adjusters for common relative dates
date.with(TemporalAdjusters.lastDayOfMonth());
date.with(TemporalAdjusters.next(DayOfWeek.MONDAY));
date.with(TemporalAdjusters.firstDayOfNextMonth());

Duration and Period

Java
Duration meetingLength = Duration.ofMinutes(90);
Duration between = Duration.between(start, end);
long minutes = between.toMinutes();

Period age = Period.between(birthDate, LocalDate.now());
System.out.println(age.getYears() + " years, " + age.getMonths() + " months");

long days = ChronoUnit.DAYS.between(launch, today);     // a single unit as a number

Duration measures time and is used with Instant and LocalTime. Period measures calendar amounts and is used with LocalDate. The difference matters across a daylight-saving boundary: one day as a Period is the same clock time tomorrow, while 24 hours as a Duration may be a different clock time.

Formatting and parsing

Java
DateTimeFormatter iso = DateTimeFormatter.ISO_LOCAL_DATE;
DateTimeFormatter custom = DateTimeFormatter.ofPattern("dd MMM yyyy", Locale.UK);

String text = today.format(custom);                     // 01 Oct 2026
LocalDate parsed = LocalDate.parse("2026-10-01");        // ISO by default
LocalDate fromCustom = LocalDate.parse("01 Oct 2026", custom);

Migrating from Date and Calendar

Java
Instant instant = legacyDate.toInstant();
Date back = Date.from(instant);

LocalDateTime local = legacyDate.toInstant()
        .atZone(ZoneId.systemDefault())
        .toLocalDateTime();

java.sql.Date sqlDate = java.sql.Date.valueOf(localDate);
LocalDate fromSql = sqlDate.toLocalDate();
AspectLegacyModern
MutabilityDate and Calendar are mutableEvery type is immutable
Thread safetySimpleDateFormat is not safeDateTimeFormatter is
Month numberingCalendar months start at 0January is 1, or use the Month enum
Concept separationDate means instant, date and time at onceA distinct type per concept
ArithmeticManual field manipulationplusDays, minusWeeks, TemporalAdjusters
ZonesImplicit and error-proneExplicit in the type
  • Mutability

    LegacyDate and Calendar are mutable
    ModernEvery type is immutable
  • Thread safety

    LegacySimpleDateFormat is not safe
    ModernDateTimeFormatter is
  • Month numbering

    LegacyCalendar months start at 0
    ModernJanuary is 1, or use the Month enum
  • Concept separation

    LegacyDate means instant, date and time at once
    ModernA distinct type per concept
  • Arithmetic

    LegacyManual field manipulation
    ModernplusDays, minusWeeks, TemporalAdjusters
  • Zones

    LegacyImplicit and error-prone
    ModernExplicit in the type

January being month 0 in Calendar is a defect that survived for fifteen years.

Common misreadings

  • "Optional eliminates NullPointerException." It eliminates null returns. An unchecked get() reintroduces the same failure.
  • "orElse is lazy." It always evaluates its argument. orElseGet is the lazy one.
  • "Optional is good for fields and parameters." It adds an object per instance and a third case for callers. Use it for return types.
  • "Optional<List<T>> is good practice." Return an empty list.
  • "LocalDateTime has a time zone." It does not, which is why it is wrong for timestamps.
  • "LocalDate.plusDays modifies the date." Every method returns a new object.
  • "Duration and Period are interchangeable." Duration is time-based, Period is calendar-based, and they differ across daylight saving.
  • "Date is fine if you are careful." It is mutable and its formatter is not thread-safe. Convert at the boundary.

Quick recall

Everything you need if you only revisit this box.

  • Optional belongs in return types, to make absence visible in the signature.
  • of throws on null, ofNullable is the usual factory, empty is the absent case.
  • orElse always evaluates; orElseGet is lazy. Prefer orElseThrow() over get(), and never call get() unchecked.
  • Chain with map, flatMap, filter, or, stream instead of unwrapping early.
  • Date-time types, one per concept: LocalDate, LocalTime, LocalDateTime, ZonedDateTime, Instant, Duration, Period.
  • Store an Instant for a moment; use LocalDate when the zone is irrelevant; use ZonedDateTime when local rules matter.
  • Everything is immutable, so plusDays returns a new value and DateTimeFormatter is a safe static final constant.
  • Duration is time-based, Period is calendar-based — they differ across a daylight-saving boundary.

Test yourself

Answer these before moving on — recall is what makes it stick.