date-fns v2 API design

kossnocorp

Sasha Koss

Posted on December 12, 2018

date-fns v2 API design

date-fns is JavaScript library for working with dates. It's a modern alternative to Moment.js.

For almost two years we've been working on a new major release — v2. We've reworked nearly every aspect of the library and added many exciting features. One of the most critical changes in v2 is the new API design. We carefully refined every function to make date-fns consistent, predictable and reliable. In this post, I tell about goals and values that helped us to design simple API that is pleasant to use.

Goals and values

When we just started working on date-fns, our only goal was to build a library for working with dates in functional style. For years we were adding more and more functions, often copying and adapting Moment.js API without second look on it.

After a while, we realized that our API and behavior is not as consistent as we want them to be. Arguments in functions doing a similar job were not always in the same order and had a different naming scheme. Sometimes functions were throwing exceptions where they shouldn't and weren't throwing when they should. I'm not talking about coercion logic; it was behind even our understanding.

The need for changes was obvious, but first, we had to define goals and values, that would help us make decisions in ambiguous cases.

Stick to JavaScript behavior whenever possible. We want date-fns to be an extension of the language and its standard library but not a substitute. We believe that it will ensure a long life of the library in the rapidly changing ecosystem.

Stick to existing standards. Instead of reinventing the wheel and relying on our subjective opinion we decided to look for current standards and well-established practices. That will provide the best compatibility with other languages and save us from mistakes that others made years if not decades ago.

Consistency. We want date-fns to be as predictable and easy to understand as possible. Function names, the order of arguments and the behavior must be consistent across the whole library.

Explicitness. date-fns should prefer explicitness over implicitness. Sometimes the latter helps to make code look cleaner. You know that feeling when a library does what you wanted it to do without saying a word. But more often it causes bugs that are hard to debug or even worse to notice.

Convenience. We want date-fns to be pleasant to use. We should help developers to avoid mistakes.

While the last three are self-explanatory, the first two requires deep dive.

Stick to JavaScript behavior whenever possible

Why

The initial idea of the library was to create a set of helpers that will work with the native Date object. We avoided introducing functionality that already existed in the standard library and named functions as they are part of it. In v2 we decided to go further and made date-fns behave like JavaScript in every aspect.

JavaScript and its behavior is often a subject of just critique. Because of the need for backward compatibility over the years, it accumulated nuances that nowadays considered as a bad language design. Yes, I'm talking about its coercion rules, NaN, null and so on.

However, behind every strange looking behavior stands logic consistent across the language. It might be covering weak parts of JavaScript, but it's consistent and if you learn it once it starts making sense.

Exceptions

In v2 we made date-fns throw TypeError and RangeError in cases when standard JavaScript functions do it.

Whenever an argument is required, JavaScript throws TypeError:

window.fetch()
//=> TypeError: 1 argument required, but only 0 present.
Enter fullscreen mode Exit fullscreen mode

From now all functions check if the passed number of arguments is less than the number of required arguments and throw TypeError exception if so:

import { addDays } from 'date-fns'
addDays()
//=> TypeError: 2 arguments required, but only 0 present
Enter fullscreen mode Exit fullscreen mode

Whenever an argument value is not in the set or range of allowed values, JavaScript throws RangeError:

(42).toFixed(-1)
//=> RangeError: toFixed() digits argument must be between 0 and 100
Enter fullscreen mode Exit fullscreen mode

From now on functions throw RangeError if optional values passed to options are not undefined or have expected values:

import { formatDistanceStrict } from 'date-fns'
formatDistanceStrict(new Date(2014, 6, 2), new Date(2015, 0, 1), {
  roundingMethod: 'qwe'
})
//=> RangeError: roundingMethod must be 'floor', 'ceil' or 'round'
Enter fullscreen mode Exit fullscreen mode

Coercion

Just like JavaScript date-fns coerce passed arguments to the expected type.

import { addDays } from 'date-fns'
addDays(new Date(1987, 1, 11), '42')
//=> Wed Mar 25 1987 00:00:00 GMT+0530 (+0530)
Enter fullscreen mode Exit fullscreen mode

Despite being the most hated aspect of the language, coercion is quite straightforward and consistent, although it leads to unexpected results in combination with arithmetic operators.

Here are the rules that we use to coerce the arguments:

Here, the columns are what type we expect the argument to be and the rows what we actually supply as an argument — for example, in addDays the first argument will be transformed by the rules from the “date” column, and the second argument by the rules from the “number” column, so addDays(1, '1') is equivalent to addDays(new Date(1), 1).

Invalid date

Date internally represented by a number so just like with Number, incorrect operations on dates results in Invalid Date (an invalid date is a date which time value is NaN):

const date = new Date()

date.setHours('nope')
//=> NaN

date
//=> Invalid Date
Enter fullscreen mode Exit fullscreen mode

date-fns reflects this behavior and will return Invalid date when you pass unconvertable values:

import { addDays } from 'date-fns/addDays'
addDays(new Date(), 'nope')
//=> 'Invalid date'
Enter fullscreen mode Exit fullscreen mode

This was one of the trade-offs that were particularly hard to make:

On the one hand, we would expect to have an exception when an argument has a wrong value. On the other hand, the exception would force us to wrap every function call into try-catch blocks that is bad developer experience. The standard JavaScript's approach to the problem is to expect the developer to be responsible for validating the user input. In the worst case scenario, the application will print Invalid Date and keep working that wouldn't happen if we'd throw exceptions.

Ongoing work

While writing the post, I found an inconsistency that we didn't consider. While toString called on an invalid date returns Invalid Date, toISOString as well as Intl API throws RangeError:

date.toString()
//=> 'Invalid Date'

date.toISOString()
//=> RangeError: Invalid time value

new Intl.DateTimeFormat('en-US').format(date)
//=> RangeError: Invalid time value
Enter fullscreen mode Exit fullscreen mode

We incorrectly applied toString behavior to format:

import { format } from 'date-fns'

const date = new Date()
date.setHours('nope')

format(date, 'yyyy-MM-dd')
//=> 'Invalid Date'
Enter fullscreen mode Exit fullscreen mode

I've opened an issue that we plan to fix before the first beta release:

format functions must throw exceptions on attempt on processing invalid dates #987

See JavaScript's toISOString and Intl API for the reasoning:

const date = new Date()

date.setHours('nope')
//=> NaN

date.getYear()
//=> NaN

date.toString()
//=> Invalid date

date.toISOString()
//=> RangeError: Invalid time value

new Intl.DateTimeFormat('en-US').format(date)
//=> RangeError: Invalid time value
Enter fullscreen mode Exit fullscreen mode

Stick to existing standards

v2 started with the change of the filenames naming scheme. I come from the Ruby world, so I thought it's a good idea to have file names in underscore format. JavaScript renaissance just started, so there wasn't a common standard. Initially, it was irritating to see so many JavaScript'ers using the camel case format for files. But eventually, I accepted the difference and decided to prioritize common practices over personal taste and adopted camel case as well.

// v1
const addDays = require('date-fns/add_days')

// v2
const addDays = require('date-fns/addDays')
Enter fullscreen mode Exit fullscreen mode

This breaking change was a turning point that allowed us to abstract from our points of views and habits and embrace existing standards and well-established practices.

One of the most significant changes was adopting Unicode Technical Standard #35 for format and parse. It caused a lot of confusion, but I believe it's worth it. You can read about that in a dedicated post.

Another standard that caused us to revisit naming schema was ISO 8601. Starting with isWithinRange function we used the word "range" for time spans:

const isWithinRange = require('date-fns/is_within_range')
isWithinRange(
  new Date(2014, 0, 3), // the date to check
  new Date(2014, 0, 1), // start
  new Date(2014, 0, 7) // end
)
//=> true
Enter fullscreen mode Exit fullscreen mode

It turned out that ISO 8601:2004 defines term "interval":

time interval: part of the time axis limited by two instants

We adopted this terminology and made interval a separate entity:

import { isWithinInterval } from 'date-fns'
isWithinInterval(new Date(2014, 0, 3), {
  start: new Date(2014, 0, 1),
  end: new Date(2014, 0, 7)
})
Enter fullscreen mode Exit fullscreen mode

It also made the code easier to read!

When a string is passed to new Date(), the JavaScript engine tries to do its best parsing it. In v1 we relied on the mechanism but then quickly learned that different browsers have different parsers and it leads to bugs that hard to find.

Starting with v2, whenever a string represents a date it must be a valid ISO 8601 string overwise you'll get Invalid Date.

Thank you for reading! I hope this is interesting and fits DEV community. Feedback is welcome!

💖 💪 🙅 🚩
kossnocorp
Sasha Koss

Posted on December 12, 2018

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related