tl;dr: Careful with validation via LocalDate parsing

The original bug

We had a bug reported on our Android app this week about invalid dates coming into the backend during a particular UI flow. The problematic section was a page where users could enter the date of an event, then we call the backend API with the date they entered. The issue they were seeing is that the date we were sending wasn’t valid for some odd reason.

It turned out that at least one user entered the date June 31st, 2022, which isn’t a real day. This was confusing, as we have validation in place to confirm what the user entered:

LocalDate.parse(
    dateString,
    DateTimeFormatter.ofPattern("MM/dd/yyyy", Locale.US)
)

We then either use the value returned from the parse(...) call or catch the DateTimeParseException and display an error. This is all logical, so why was June 31st allowed?

LocalDate parsing with DateTimeFormatter

It turns out the default approach with some invalid dates is to effectively round the result to the end of a month. This means code like this:

LocalDate.parse(
    "06/31/2022", DateTimeFormatter.ofPattern("MM/dd/yyyy")
)

actually has the date June 30th rather than throw a DateTimeParseException.

Now, if we used "02/33/2022" instead, that throws the exception as you’d expect. It turns out that if the day of the month is one that’s valid in some cases (meaning the date is 1-31) but is outside the limit of a given month, it uses that last day instead.

I then decided to give a different parser a shot.

LocalDate parsing with SimpleDateFormat

SimpleDateFormat has a similar enough syntax, so I figured this could be an easy fix.

SimpleDateFormat("MM/dd/yyyy").parse(dateString)

Nope.

SimpleDateFormat actually goes the other way, meaning if we’re past the valid days for a month, it keeps adding on days. In other words, June 31st becomes July 1st and something like Feb. 31st becomes March 3rd.

Remember the joke about how March 2020 seemed to just go on forever during the pandemic? That works with SimpleDateFormat. It can handle a date like March 406th, 2020 and give you April 10th, 2021. This is in no way useful, but it’s at least amusing.

Now, there is a way to make SimpleDateFormat work a bit better here. We can always set the isLenient flag to false, meaning only valid dates get parsed.

val dateFormat = SimpleDateFormat("MM/dd/yyyy").apply { isLenient = false }
dateFormat.parse(dateString)

This is much better as it actually does what we want. We need to catch an exception if the parsing fails, which I don’t love, but it works fine. Also, we can always throw this into a function:

fun parseLocalDateOrNull(dateString: String, dateFormat: String) =
    try {
        SimpleDateFormat(dateFormat).apply { isLenient = false }.parse(dateString)
    } catch (e: ParseException) {
        null
    }

This works, but I really felt like DateTimeFormatter should work as well. Given that SimpleDateFormat needed that isLenient flag, maybe something similar lives on DateTimeFormatter.

Plus, I saw this blog where they use DateTimeFormatter exactly as I expected, so I knew it had to work in some fashion.

Back to DateTimeFormatter

It turns out, the key with DateTimeFormatter is the ResolverStyle class. We can add an extra call, .withResolverStyle(ResolverStyle.STRICT), to make the call throw an exception if the date string is invalid in any way.

In that post I mentioned before, they used the BASIC_ISO_DATE formatter. It turns out that BASIC_ISO_DATE internally uses the parseStrict() function when building the formatter, which means we’re on the right track!

Time for the new DateTimeFormatter:

LocalDate.parse(
    dateString,
    DateTimeFormatter
        .ofPattern("MM/dd/yyyy", Locale.US)
        .withResolverStyle(ResolverStyle.STRICT)
)

Nope again.

The above parser threw an exception for "06/31/2022", but it also threw one for "06/30/2022" and every other date I tried. What’s going on here? Now it’s too strict!

As it turns out, the issue is with the "yyyy" part of the format. As mentioned in this StackOverflow post:

Java 8 uses uuuu for year, not yyyy. In Java 8, yyyy means “year of era” (BC or AD) and the error message complains that MonthOfYear, DayOfMonth and YearOfEra is not enough information to construct the date because era is not known.

This means that the code I wanted in the first place is this block:

LocalDate.parse(
    dateString,
    DateTimeFormatter
        .ofPattern("MM/dd/uuuu", Locale.US)
        .withResolverStyle(ResolverStyle.STRICT)
)

With both DateTimeFormatter and SimpleDateFormat, the default functionality isn’t doing what I had expected and caused some trouble. Moral of the story: make sure you know how you get your dates and once again, dates/times are the bane of a developer’s existence.