Migrating Date.toString() to java.time APIs

When updating to Error Prone version 2.4.0, I noticed that it began flagging all usages of the java.util.Date class:

warning: [JdkObsolete] Date has a bad API that leads to bugs; prefer java.time.Instant or LocalDate.

I’m glad it does this. Date has serious flaws – and if you’re curious you can read more about the bug pattern here. In many cases it is very easy to migrate to Instant or LocalDate while not changing the behavior of your existing code, but I did come across one case in particular where migrating to java.time APIs wasn’t so straightforward.

Date.toString()

Unlike most things about Date, the toString() method actually has relatively simple semantics. Here’s what the Javadocs state:


Converts this Date object to a String of the form:

dow mon dd hh:mm:ss zzz yyyy

where:

  • dow is the day of the week (Sun, Mon, Tue, Wed, Thu, Fri, Sat).
  • mon is the month (Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec).
  • dd is the day of the month (01 through 31), as two decimal digits.
  • hh is the hour of the day (00 through 23), as two decimal digits.
  • mm is the minute within the hour (00 through 59), as two decimal digits.
  • ss is the second within the minute (00 through 61), as two decimal digits.
  • zzz is the time zone (and may reflect daylight saving time). Standard time zone abbreviations include those recognized by the method parse. If time zone information is not available, then zzz is empty – that is, it consists of no characters at all.
  • yyyy is the year, as four decimal digits.

This is all simple enough, and if your old code used this method it probably behaved acceptably well enough for its application. The problem only shows up when you try to migrate over to Instant, which has very different behavior with its toString() method:


Returns a string representation of this instant using ISO-8601 representation.

The format used is the same as DateTimeFormatter.ISO_INSTANT.


Here’s how the two classes behave side by side:

public class TestDate
{
    public static void main(String[] args)
    {
        Date now = new Date();

        System.out.println(now);
        System.out.println(now.toInstant());
    }
}

prints:

Sun Jun 28 15:49:34 CDT 2020
2020-06-28T20:49:34.967Z

If we want to preserve our code’s existing behavior, we’re going to have to do a little work to format our Instants in the same manner.

DateTimeFormatter

The java.time solution for custom date formats is the DateTimeFormatter class. Defining your own is pretty easy to do.

To create one that formats our Instants the same way Date.toString() produced its output, I started with the pattern from the Date.toString() Javadoc:

private static final DateTimeFormatter DATE_FORMATTER
        = DateTimeFormatter.ofPattern("dow mon dd hh:mm:ss zzz yyyy");

Trying to run this will throw an IllegalArgumentException because dow and mon are not actually correct symbols for date format patterns. Consulting the handy chart in the DateTimeFormatter docs, I learned that days of the week are represented by Es and months are represented by Ms. Swapping those out, we now have:

private static final DateTimeFormatter DATE_FORMATTER
        = DateTimeFormatter.ofPattern("EEE MMM dd hh:mm:ss zzz yyyy");

Now to add this into our test program to verify the results:

public class TestDate
{
    /**
     * A date formatter that formats dates identically to {@link Date#toString()}.
     */
    private static final DateTimeFormatter DATE_FORMATTER
            = DateTimeFormatter.ofPattern("EEE MMM dd hh:mm:ss zzz yyyy");

    public static void main(String[] args)
    {
        Date now = new Date();

        System.out.println(now);
        System.out.println(DATE_FORMATTER.format(now.toInstant()));
    }
}

I was pretty sure this was going to work, but when I ran it I got an UnsupportedTemporalTypeException with the message "Unsupported field: DayOfWeek".

What in the world?!? How is the DayOfWeek field unsupported? I just pulled it straight from the chart in the Javadoc!

Time Zones

While I didn’t come across anyone with this exact problem online, this post on Stack Overflow did help me figure out what is going on here.

By default, DateTimeFormatters have no time zone. Even though Instant models a single instantaneous point on the timeline, the DateTimeFormatter uses its own internal time zone information for formatting purposes. Without a time zone set, the DateTimeFormatter can’t determine which day of the week our Instant represents.

So we need to set a time zone for our DateTimeFormatter, but which one should we use? The obvious answer is "the same one that Date uses". Recall from earlier how Date.toString() printed out the time with a time zone present:

Sun Jun 28 15:49:34 CDT 2020

But as it turns out, Date doesn’t have its own time zone. It only stores a single long internally that represents the number of milliseconds since the Unix epoch UTC.

Where did that time zone come from then? If you look at the Date.toString() implementation, it actually uses a Calendar instance to perform the formatting. This Calendar uses the JVM’s default time zone when generating the String representation. Yuck …

Adding a Time Zone to DateTimeFormatter

So where does that leave us? We knew we had to add a time zone to our DateTimeFormatter to avoid the runtime exception, and now we know that we need to use the JVM default to mirror the Date.toString() behavior.

Here’s what the new version looks like:

public class TestDate
{
    /**
     * A date formatter that formats dates identically to {@link Date#toString()}.
     */
    private static final DateTimeFormatter DATE_FORMATTER
            = DateTimeFormatter.ofPattern("EEE MMM dd hh:mm:ss zzz yyyy").withZone(ZoneId.systemDefault());

    public static void main(String[] args)
    {
        Date now = new Date();

        System.out.println(now);
        System.out.println(DATE_FORMATTER.format(now.toInstant()));
    }
}

However, when I ran this I got the following output:

Sun Jun 28 16:30:18 CDT 2020
Sun Jun 28 04:30:18 CDT 2020

Close, but no cigar. In the pattern we pulled from the Date.toString() Javadoc, the symbol for hour was a lower-case ‘h’. The output we are trying to match uses 24-hour time, so we need to switch that to a capital ‘H’. Alas, we have arrived at our final version:

private static final DateTimeFormatter DATE_FORMATTER
        = DateTimeFormatter.ofPattern("EEE MMM dd HH:mm:ss zzz yyyy").withZone(ZoneId.systemDefault());

Which gives us exactly the output we want:

Sun Jun 28 17:50:56 CDT 2020
Sun Jun 28 17:50:56 CDT 2020

Anyways, if you come across this situation I hope this post will save you some trouble. Leave a comment and let me know what you think!

Leave a Reply