Stop. Java Time!

Modeling Time

Java Time and its predecessor Joda Time are the workhorses of countless systems around the world. Accounting for leap years and timezones when calculating payroll and coordinating bank transfers is surprisingly difficult. While most programmers agree that it works well enough, the abstraction leaves something to be desired.

Take something simple like adding a month to a date. The results may be surprising. According to Java:

  • One month after March 15, 2015 is April 15, 2015.

  • One month after March 30, 2015 is April 30, 2015.

  • One month after March 31, 2015 is April 30, 2015.

(defn add-1-month [date] 
  (-> (java.time.LocalDate/parse date)
      (.plusMonths 1) 
      str))
(add-1-month "2015-03-15")
0.3s
Clojure
"2015-04-15"

It both makes sense and feels incorrect. In part because Java uses methods called plusMonths and plusDays and we expect addition to be associative: inline_formula not implemented.

Adding a day and then adding a month should be the same as adding a month and then adding a day. But it's not always true. Take the example of May 30th:

(defn add-1-day [date] 
  (-> (java.time.LocalDate/parse date)
      (.plusDays 1) 
      str))
(add-1-month (add-1-day "2015-03-30"))
0.1s
Clojure
"2015-04-30"
(add-1-day (add-1-month "2015-03-30"))
0.0s
Clojure
"2015-05-01"

And yet Java Time endures. In part it endures because this is a very hard problem. Eric Evans addresses the nuance of time math in his talk Modelling Time. He suggests that arithmetic is the wrong abstraction. Evans describes taking inspiration from Clojure, a list processing language at heart, to develop a different abstraction.

  • Time a list of units. So July holds a list of days July 1, July 2, ... July 31.

  • Intervals is where sequences meet. July 1 meets July 2. July meets August. 2020 meets 2021 (if we're lucky).

Evans’ model includes an implementation of Allen’s interval algebra that is beyond the scope of this post. Suffice to say, Java Time is incredibly popular, but there are other approaches that may be more accurate and less complex.

Even something like a regular sequence of intervals - like the tick of the clock - can be unintuitive. In Date and Time are more difficult than you think, Alex Bolboaca and Adrian Bolboaca discuss time zones, the international date line, and relativistic time.

Relativistic time is a real mind-bender with practical consequences. For examples, engineers must take into account the speed of orbiting satellites and the curvature of spacetime when estimating GPS coordinates. To stay in lock-step with clocks on the Earth’s surface, satellite clocks must tick 38 microseconds faster in aggregate every day. Errors in time-keeping can lead to kilometer-sized positioning errors that get worse over time. The GPS on your phone would be worthless if these clocks did not observe the rules of Einstein’s theory of relativity.

java.time

When staring a project with Java 8 or later, use Java Time instead of Joda Time. Stephen Colebourne is brief when he discusses the advantages: "The java.time library contains many of the lessons learned from Joda-Time, including stricter null handling and a better approach to multiple calendar systems."

This is what it looks like using Java interop.

(java.time.LocalDateTime/now)
0.3s
Clojure
Vector(4) [java.time.LocalDateTime, "0x42ace440", "2020-07-20T11:13:44.307", Map]

LocalDate, LocalTime, and LocalDateTime will be preferred for these examples. They are easy to work with and human-readable. Other options include Instant (machine time since the Unix epoch (1970-01-01T00:00:00Z)) and Zoned (timezones) times.

java.time Wrappers

We're Clojure programmers and we like wrappers! There are two main libraries for wrapping java.time, Clojure.Java-Time and cljc.java-time.

Clojure.Java-Time

Clojure.Java-Time is a popular Clojure wrapper for Java 8 Date-Time API. It focuses on the JVM. You're on your own if you want to interoperate with ClojureScript.

(require '[java-time :as java-time])
(-> (java-time/local-date 2020 10 02)
    java-time/year
    java-time/format)
7.5s
Clojure
"2020"

Date/time arithmetic is also easy.

(java-time/time-between 
 (java-time/local-date 1990 10 02) 
 (java-time/local-date 2020 10 01) 
 :years) 
0.0s
Clojure
29

The Clojure.Java-Time namespace provides all the common date/time functions. However, the deep dive of this tutorial will be reserved for cljc.java-time because it was built with platform interop in mind.

CLJ ↔ CLJS ↔ EDN

Henry Widd dives into his contributions to these libraries in Cross Platform DateTime Awesomeness at Clojure/north 2019. I'm going to group his two libraries together because they provide a way to seamlessly move time data from ClojureScript ↔ Clojure ↔ edn (or any other serialized transmission/storage).

cljc.java-time

Working with LocalDate is simple. require the namespace and parse a well-formed string. The result is a java.time.LocalDate.

(require '[cljc.java-time.local-date :as ld])
(def date-string "2015-02-12")
(ld/parse date-string)
0.6s
Clojure
Vector(4) [java.time.LocalDate, "0x71d3077e", "2015-02-12", Map]

Working with a LocalDate is the same as it is in native Java. It's just a little easier to read.

(defn add-90-days [date]
  (-> (ld/parse date)
      (ld/plus-days 90)
      str))
(add-90-days date-string)
0.1s
Clojure
"2015-05-13"

Getting today's year is straight forward.

(ld/get-year (ld/now))
0.1s
Clojure
2020

But getting today's day of the week requires a little more work. Whereas java.time.LocalDate is an instance of Java's root object class, DayOfWeek is an Enum object: java.lang.Objectjava.lang.Enumjava.time.DayOfWeek.

  • ld/now returns a java.time.LocalDate

  • ld/get-day-of-week returns a java.time.DayOfWeek

(require '[cljc.java-time.day-of-week :as dow])
(dow/to-string (ld/get-day-of-week (ld/now)))
0.3s
Clojure
"MONDAY"
(ld/get-day-of-week (ld/now))
0.1s
Clojure
Vector(4) [java.time.DayOfWeek, "0xabe1c65", "MONDAY", Map]

time-literals

Literals

A literal is a form that returns itself when evaluated. For example, the s-expression (+ 3 4) represents both -

  1. A syntax tree when printed as a (list) that contains three atoms +, 3, and 4.

  2. A form when evaluated by Clojure. It includes a symbol for addition, +, and two numeric literals, 3 and 4.

The results of evaluating two different forms:

  • 33 (the numeric literal evaluates to a numeric literal)

  • (+ 3 4)7 (the s-expression evaluates to a numeric literal)

(str "Evaluate `3`: " 3 ", Evaluate `(+ 3 4)`: " (+ 3 4))
0.1s
Clojure
"Evaluate `3`: 3, Evaluate `(+ 3 4)`: 7"

Literals go beyond numbers. They also include strings, characters, nil, booleans, keywords, symbolic values (##Inf (∞), ##-Inf (-∞), and ##NaN (Not a Number)), collections (lists, vectors, maps, and sets), and records (deftype and defrecord).

Tagged Literals

Clojure 1.4 introduced custom literals marked by a tag. For example, 1.4 shipped with built-in tagged literals for #uuid and #instant. Prefixing the literal "efea38cd-0db8-4f66-b3ab-c50b4c08d907" with #uuid generates a java.util.UUID type:

#uuid "efea38cd-0db8-4f66-b3ab-c50b4c08d907"
0.1s
Clojure
Vector(4) [java.util.UUID, "0x349f3552", "efea38cd-0db8-4f66-b3ab-c50b4c08d907", Map]

Clojure reads the literal string first, "efea38cd-0db8-4f66-b3ab-c50b4c08d907", and then invokes the tagged literal function on the string (or whatever literal happens to follow the tag).

In this example, #uuid dispatches java.util.UUID/fromString on "efea38cd-0db8-4f66-b3ab-c50b4c08d907". The generic string is now a meaningful bit of data.

(= "efea38cd-0db8-4f66-b3ab-c50b4c08d907" (java.util.UUID/fromString "efea38cd-0db8-4f66-b3ab-c50b4c08d907"))
0.1s
Clojure
false
(= #uuid "efea38cd-0db8-4f66-b3ab-c50b4c08d907" (java.util.UUID/fromString "efea38cd-0db8-4f66-b3ab-c50b4c08d907"))
0.1s
Clojure
true

#uuid cannot prefix a string that does not qualify as a valid UUID.

(try 
  (java.util.UUID/fromString "not-a-uuid") ;; #uuid "not-a-uuid"
  (catch IllegalArgumentException e (.getMessage e)))
0.1s
Clojure
"Invalid UUID string: not-a-uuid"

Tagged literals are like a constructor for an object. But they are succinct and easily read by humans - making them a perfect fit for structured data. They are the "extensible" part of the extensible data notation (edn) specification.

Time Literals

Edn is a preferred format for serializing and deserializing data. Here's how we might store and send time and date information over the wire.

Tag a generic date string with #inst to generate a java.util.Date object:

(print (type #inst "2020-05-11"))
#inst "2020-05-11"
0.7s
Clojure
#inst "2020-05-11T00:00:00.000Z"

Store that information as a string. Later, if is read by a reader that recognizes the #inst tag, it will automatically construct a java.util.Date object. This happens automatically when reading a string using clojure.edn:

(prn-str #inst "2020-05-11") ; ⇒ "#inst "2020-05-11T00:00:00.000-00:00" "
(clojure.edn/read-string (prn-str #inst "2020-05-11"))
0.1s
Clojure
#inst "2020-05-11T00:00:00.000Z"

It is now possible to operate on the date and time information - add days, find the difference between two dates, grab the year, etc....

(import '(java.text SimpleDateFormat))
;; Grab the year
(->> (prn-str #inst "2020-05-11")
     (clojure.edn/read-string)
     (.format (SimpleDateFormat. "yyyy")))
0.1s
Clojure
"2020"

But there is a big problem! We don't want to work with java.util.Date, otherwise known as Joda Time. This is where the time-literals package comes in handy.

As a reminder, I want to operate on an object like this one so I have access to all the handy date and time functions supplied by cljc.java-time:

(ld/now)
0.1s
Clojure
Vector(4) [java.time.LocalDate, "0x24942c5d", "2020-07-20", Map]

Here are some of the tagged literals supplied by time-literals:

#time/month "JUNE"
#time/period "P1D"
#time/date "2039-01-01"
#time/date-time "2018-07-25T08:08:44.026"
#time/zoned-date-time "2018-07-25T08:09:11.227+01:00[Europe/London]"
#time/offset-date-time "2018-07-25T08:11:54.453+01:00"
#time/instant "2018-07-25T07:10:05.861Z"
#time/time "08:12:13.366"
#time/duration "PT1S"
#time/year "3030"
#time/year-month "3030-01"
#time/zone "Europe/London"
#time/day-of-week "TUESDAY"
Extensible Data Notation

Printing the date to a string looks like this:

(require '[time-literals.read-write :as time-read])
(def time-date-string (time-read/print-date "2015-12-11"))
time-date-string
0.5s
Clojure
"#time/date "2015-12-11""

Using the edn/read-string function with the #time/date tag is almost identical to #inst. The only difference is the supplied reader: {:readers time-read-prn/tags}. This is necessary because Clojure does not have any built-in #time/date functionality.

(clojure.edn/read-string {:readers time-read/tags} time-date-string)
0.0s
Clojure
Vector(4) [java.time.LocalDate, "0x4d952746", "2015-12-11", Map]

The tags make it easy to read a string, December 11, 2015, and add 90 days to generate the result March 10, 2016:

(require '[cljc.java-time.format.date-time-formatter :as formatter])
(-> (clojure.edn/read-string {:readers time-read/tags} time-date-string)
    (ld/plus-days 90)
    (ld/format  (formatter/of-pattern "MMMM dd, yyyy")))
0.4s
Clojure
"March 10, 2016"

If I want to get the year from a tagged literal string pulled off the wire or a file:

(->> "#time/date \"2011-01-01\""
     (clojure.edn/read-string {:readers time-read/tags})
     (ld/get-year))
0.0s
Clojure
2011

Appendix

Joda Time

Generate a java.util.Date.

(import '(java.util Date))
(import '(java.util Calendar))
(import '(java.util GregorianCalendar))
(import '(java.text SimpleDateFormat))
(Date.)
0.1s
Clojure
#inst "2020-07-20T11:13:55.322Z"
(= (.getTime (GregorianCalendar.)) (Date.))
0.1s
Clojure
false
(.parse (SimpleDateFormat. "yyyy-MM-dd") "2019-04-30")
0.0s
Clojure
#inst "2019-04-30T00:00:00.000Z"
(.getWeeksInWeekYear (GregorianCalendar.))
0.1s
Clojure
52
(-> (doto (GregorianCalendar/getInstance) (.setTime (Date.)))
		(.get Calendar/WEEK_OF_YEAR))
0.1s
Clojure
30
(->> (Date.)
     (.format (SimpleDateFormat. "yyyy-MM-dd")))
0.0s
Clojure
"2020-07-20"

Java Calls and System Calls

A few notes on Java calls in Clojure.

  • dot "." is a special form for Java methods and attributes

  • double-dot ".." enables call chaining

(defn add-90-days [date] 
  (let [date-format java.time.format.DateTimeFormatter/ISO_LOCAL_DATE]
    (.. (java.time.LocalDate/parse date date-format)
        (plusDays 90)
        (format date-format))))
(add-90-days "2015-02-13")
0.1s
Clojure
"2015-05-14"
(doto (new java.util.HashMap) (.put "a" 1) (.put "b" 2))
(.. System (getProperties) (get "os.name"))
0.1s
Clojure
"Linux"

Deps

{:deps
 {org.clojure/clojure {:mvn/version "1.10.0"}
  org.clojure/tools.deps.alpha
  {:git/url "https://github.com/clojure/tools.deps.alpha.git"
   :sha "f6c080bd0049211021ea59e516d1785b08302515"}
  compliment {:mvn/version "0.3.9"}
  time-literals {:mvn/version "0.1.4"}
  cljc.java-time {:mvn/version "0.1.11"}
  clojure.java-time {:mvn/version "0.3.2"}}}
deps.edn
Extensible Data Notation
Runtimes (1)