A Strange Moment.js — 3 Gotchas for Working & Testing with Datetimes

A Strange Moment.js — 3 Gotchas for Working & Testing with Datetimes

·

13 min read

Introduction to Moment.js

Moment.js is the most commonly used JavaScript library to deal with datetimes, however for many use cases today, it may not be the best library to use given the issues of mutability, file size bundles and that it was designed for the previous era of the JavaScript ecosystem. Even the library maintainers themselves discourage you to use Moment.js in new projects going forward.

Nonetheless, there are many developers who still have to work with this library because many existing production projects use it heavily, and it take significant effort to do a code revamp to migrate to use another immutable datetime library. Hence, this article is meant to highlight some strange gotchas for anyone who still has to deal with Moment.js. And for those who don’t have to, this article also serves as an interesting read for those who are curious about problems that can come from Moment.js & working with DateTime.

Some examples given here are inspired based on what I have done at work. There will also be Jest tests in the article to illustrate that the functionalities work as intended.

If you are not familiar with Jest or find it a bother to set it up, you can also simply console.log the relevant variables to check the values manually. Otherwise you can refer to the source code found here at this GitHub repository on how Jest can be setup for a simple Node.js project.

Gotcha 1: Mutability of DateTime objects

In many DateTime manipulation test cases, we would want to create DateTime objects in a chronological order. Instead of constantly writing hard coded values like “2022-03-21”, “2022-03-22”, I could create relative moment objects for testing.

const today = moment()
const oneWeekLater = today.add(1, "week")
const twoWeeksLater = today.add(2, "week")

However, when we log the 3 values, it turns out that they all have the same date.

today.format() // "2022-03-17T15:16:52.538Z"
oneWeekLater.format() // "2022-03-17T15:16:52.538Z"
twoWeeksLater.format() // "2022-03-17T15:16:52.538Z"

This is because it turns out that all 3 constants refer to the same moment object, with the same datetime value. When we perform .add, moment mutates the original object rather than mutating a copy of that object.

Solution: Clone the moment object

Hence, the solution to this problem is rather straight forward — we simply have to clone the moment object before performing arithmetic operations.

const today = moment()
const oneWeekLater = today.clone().add(1, "week")
const twoWeeksLater = today.clone().add(2, "week")
today.format() // "2022-03-17T15:16:52.538Z"
oneWeekLater.format() // "2022-03-24T15:16:52.538Z"
twoWeeksLater.format() // "2022-03-31T15:16:52.538Z"

That way, we will get 3 unique moment objects and their respective datetime values.


Gotcha 2: moment().isValid()

For backend applications, it is pretty common to validate that the properties found in the request body are the specified type that we expect them to be. For validating that the requestBody has a property of the DateTime string format, this is a simple way of how we can create a function to check it.

const isDateTimeValid = (requestBody) => {
    return moment(requestBody.dateTime).isValid()
}

This function does work as intended for the happy paths.

describe("isDateTimeValid", () => {
    it("YYYY-MM-DD", () => {
        const requestWithDate = {
            dateTime: "2020-11-12"
        }
        expect(isDateTimeValid(requestWithDate)).toBe(true)

    })

    it("YYYY-MM-DDThh:mm:ss", () => {
        const requestWithDateTime = {
            dateTime: "2022-03-17T10:00:00"
        }
        expect(isDateTimeValid(requestWithDateTime)).toBe(true)
    })

    it("ISO string", () => {
        const requestWithISOString = {
            dateTime: "2022-03-17T15:16:52.538Z"
        }
        expect(isDateTimeValid(requestWithISOString)).toBe(true)
    })
})

Running Jest, I get the following expected results.

PASS  ./momentjs.test.js
  isDateTimeValid
    ✓ should return true for YYYY-MM-DD (1 ms)
    ✓ should return true for YYYY-MM-DDThh:mm:ss (1 ms)
    ✓ should return true for ISO string (1 ms)

Now let’s write some sad paths. I will throw in null and some random values that don’t make sense as DateTime.

describe("isDateTimeValid", () => {
    it.each([
              null,
        1,
        -1,
        12312312,
        "hehe"
    ])("should return false for %p", (dateTime) => {
        const request = { dateTime }
        expect(isDateTimeValid(request)).toBe(false)
    })
}

Running Jest, I get the following results. It returned false for null and invalid string values, but not for numbers.

FAIL  ./momentjs.test.js
  isDateTimeValid
    ✓ should return true for YYYY-MM-DD (2 ms)
    ✓ should return true for YYYY-MM-DDThh:mm:ss (1 ms)
    ✓ should return true for ISO string
    ✓ should return false for null
    ✕ should return false for 1 (2 ms)
    ✕ should return false for -1
    ✕ should return false for 12312312
    ✓ should return false for "hehe" (7 ms)

This happens because if we try to create a moment with a generic number, we do get a datetime.

moment(1).format() // 1970-01-01T07:30:00+07:30
moment(-1).format() // 1970-01-01T07:29:59+07:30
moment(12312312).format() // 1970-01-01T10:55:12+07:30

Notice that all of these correspond to the same date, but with a different time.

This happens because Moment interprets number values that we pass to the constructor as the number of seconds since the Unix Epoch. Therefore, we will need to tweak our datetime validity method to make it work as intended for valid datetime strings.

Solution: Enable strictMode

A common solution to address these edge cases is to enforce strict mode. How it works is that we have to pass in a format that we are parsing the datetime, and set strict mode boolean as true.

moment(dateTime, "YYYY-MM-DD", true)

If you just change the validity function to use the above, then all the test cases that should be failing will fail correctly, and only the happy case of YYYY-MM-DD will pass.

FAIL  ./momentjs.test.js
  isDateTimeValid
    ✓ should return true for YYYY-MM-DD
    ✕ should return true for YYYY-MM-DDThh:mm:ss (1 ms)
    ✕ should return true for ISO string (1 ms)
    ✓ should return false for null (1 ms)
    ✓ should return false for 1
    ✓ should return false for -1
    ✓ should return false for 12312312 (1 ms)
    ✓ should return false for "hehe"

If we want the other 2 happy cases to continue passing, the solution is to use the ISO 8601 format, which allows for partial timestamps.

export const isDateTimeValid = (requestBody) => {
    return moment(requestBody.dateTime, moment.ISO_8601, true).isValid()
}

Now, all our tests pass correctly 🎉

PASS  ./momentjs.test.js
  isDateTimeValid
    ✓ should return true for YYYY-MM-DD
    ✓ should return true for YYYY-MM-DDThh:mm:ss
    ✓ should return true for ISO string
    ✓ should return false for null
    ✓ should return false for 1
    ✓ should return false for -1
    ✓ should return false for 12312312
    ✓ should return false for "hehe"

However, if we think about the problem we are trying to solve further — to validate the request body containing the datetime format — we should be standardizing the datetime format that we want to accept from our request body rather than allowing a loose list of datetime formats. This depends on the type of consumer that’s calling your API, but generally, the ISO string format in UTC is the safest to handle, since most databases & servers run on UTC by default.

Gotcha 3: Parsing DateTime strings

Many times, developers tend to use open source libraries without fully reading the documentation, and they spend hours fixing problems caused by lack of understanding of the fundamental concepts that the library is built upon. The DateTime string format parsed by Moment.js is one of such fundamental concepts which I’m guilty of just skimming over.

There was a time where I had to retrieve the weekday of a specific date. To do this in Moment.js, all we have to do is call the moment object’s .isoWeekday() function.

moment("2022-03-14").isoWeekday(); // 1 -> Monday
moment("2022-03-17").isoWeekday(); // 4 -> Thursday
moment("2022-03-20").isoWeekday(); // 7 -> Sunday

However, it is rather common for many databases to store some properties as DateTime even though the Date type could be sufficient. When we retrieve these date properties from the DB they would usually return in the format of “YYYY-MM-DDThh:m:ss.SSSZ”.

When we try to find the weekday of DateTime strings, you will notice a strange behavior.

// normal date
moment("2022-03-17").isoWeekday(); // 4 -> Thursday

// datetime retrieved from DB
moment("2022-03-17T22:16:52.538Z").isoWeekday(); // 5 -> Friday

// failed attempts to get the right date
moment("2022-03-17T22:16:52.538Z").startOf("day").format("YYYY-MM-DD"); // 2022-03-18
moment("2022-03-17T22:16:52.538Z").startOf("day").isoWeekday(); // 5 -> Friday

In the examples above, the date portion of the strings are all the same, but the weekday returned is different from expected. For me, the moment parses the DateTime as a day later than expected. For you, you may end up getting a different DateTime that what I’ve written there 😆 If it is indeed different, it is because of timezone differences.

World time buddy is a nice website to go to if you want to compare DateTime between different countries and regions.

If you are not familiar with timezone offsets, meeting this problem can be very frustrating. Here’s an illustration of the string format is parsed by Moment.js.

Structure of datetime.png

Having a ‘Z’ in the DateTime string meant that this DateTime string is given in UTC. This meant that if I am residing in Singapore, a GMT+8 timezone, when I use moment to parse this UTC DateTime string, I will get a local DateTime which is 8 hours ahead.

moment("2022-03-17T22:16:52.538Z").format() // 2022-03-18T06:16:52+08:00
moment("2022-03-17T22:16:52.538").format() // 2022-03-17T22:16:52+08:00

If somehow you are saving "2022-03-17T22:16:52.538Z" in your DB and this refers to DateTime in your local timezone rather than a DateTime in UTC (yes this happens 😂), there are 2 ways you can parse this correctly.

Solution 1

The first method is to simply chop off the ‘Z’ part, since it is what causes the timezone offset.

const dateTime = "2022-03-17T22:16:52.538Z"
moment(dateTime.splice(dateTime.length - 1, 1)).format() // 2022-03-17T22:16:52+08:00

Solution 2

The second method is more readable, which is to tell Moment.js explicitly that this DateTime string is actually not in UTC.

moment("2022-03-17T22:16:52.538Z").utc(false).format() // 2022-03-17T22:16:52+08:00

Now that we have a better understanding of how Moment.js parses DateTime strings and why this gotcha happens, remember how I was trying to find out the weekday of a given date?

Let me introduce you to a fun context for that! The problem will be presented in just dates rather than DateTime for simplicity, but you can certainly replace the dates below with DateTime strings and what you have learnt so far ✨

Use case: Find the most recent instance of a given weekday

Imagine that you are a backend developer who has to implement a special weekly check-in reward event for a game. Some requirements are:

  • Admins are able to configure a specific day of the week for players to check-in to this event e.g. Wednesday
  • The game has to call the API that you have created to get the most recent instance of Wednesday that the players can check in to the event, to determine whether to show them the most recent checkpoint that they have missed or show them that they can check in today.

Now let’s visualize the scenarios we have to address with some actual dates. Let’s start off with just dates.

Case 1: Get the given weekday from last week

For example, if today is Tuesday 15 March 2022, the most recent instance of Wednesday from this date will be Wednesday 9 March 2022.

This scenario can be translated into a test as such.

describe("getRecentInstanceOfWeekday", () => {
    it("should get last wednesday if today is tuesday", () => {
        expect(getRecentInstanceOfWeekday("2022-03-15", 3)).toBe("2022-03-09");
    })
})

Unfortunately, there isn’t such a utility function available in Moment.js. There are many use cases like this where we would have to write our own. However, that being said, Moment.js does provide arithmetic operations that could aid us in writing the solution.

To address the first test case, we can get the Wednesday of the previous week.

export const getRecentInstanceOfWeekday = (currentDate, dayOfTheWeek) => {
    return moment(currentDate).subtract(1, "weeks").day(dayOfTheWeek).format("YYYY-MM-DD")
}

Running Jest, the test case will pass as intended.

getRecentInstanceOfWeekday
    ✓ should get last wednesday if today is tuesday (5 ms)

Now let’s move to another scenario, where we should be getting the given weekday from current week instead.

Case 2: Get the given weekday from current week

  • If today is Thursday 17 March 2022, we should be getting Wednesday 16 March 2022.
  • Likewise if today is Wednesday 16 March 2022 itself, we should be getting Wednesday 16 March 2022.

This translates to the 2 new test cases below.

describe("getRecentInstanceOfWeekday", () => {
    it("should get last wednesday if today is tuesday", () => {
        expect(getRecentInstanceOfWeekday("2022-03-15", 3)).toBe("2022-03-09");
    })

    it("should return this week's wednesday if today is thursday", () => {
        expect(getRecentInstanceOfWeekday("2022-03-17", 3)).toBe("2022-03-16");
    })

    it("should return today if today is wednesday", () => {
        expect(getRecentInstanceOfWeekday("2022-03-16", 3)).toBe("2022-03-16");
    })
})

However, with our previous implementation, when we run this test with Jest, we will see the following test failure results.

● getRecentInstanceOfWeekday › should return this week's wednesday if today is thursday

    expect(received).toBe(expected) // Object.is equality

    Expected: "2022-03-16"
    Received: "2022-03-09"

      42 |
      43 |     it("should return this week's wednesday if today is thursday", () => {
    > 44 |         expect(getRecentInstanceOfWeekday("2022-03-17", 3)).toBe("2022-03-16");
         |                                                             ^
      45 |     })
● getRecentInstanceOfWeekday › should return today if today is wednesday

    expect(received).toBe(expected) // Object.is equality

    Expected: "2022-03-16"
    Received: "2022-03-09"

      46 |
      47 |     it("should return today if today is wednesday", () => {
    > 48 |         expect(getRecentInstanceOfWeekday("2022-03-16", 3)).toBe("2022-03-16");
         |                                                             ^
      49 |     })
      50 | })

Solution

With the test cases we have written, we notice a pattern:

  • if today is on a weekday < Wednesday, we return the Wednesday of the previous week
  • if today is on a weekday ≥ Wednesday, we return the Wednesday of the current week

Knowing this pattern and how isoWeekday() works, we can write a solution.

export const getRecentInstanceOfWeekday = (currentDate, dayOfTheWeek) => {
    if (moment(currentDate).isoWeekday() < dayOfTheWeek) {
        return moment(currentDate).subtract(1, "weeks").isoWeekday(dayOfTheWeek).format("YYYY-MM-DD")
    }

    return moment(currentDate).isoWeekday(dayOfTheWeek).format("YYYY-MM-DD");
}

Now, the previous tests will pass.

PASS  ./momentjs.test.js
  getRecentInstanceOfWeekday
    ✓ should get last wednesday if today is tuesday (1 ms)
    ✓ should return this week's wednesday if today is thursday
    ✓ should return today if today is wednesday (1 ms)

We can also write more test cases and generalize them as such in Jest.

describe("getRecentInstanceOfWeekday", () => {
    it.each([
        ["given day is Wed, when today is Tue", "2022-03-15", 3, "2022-03-09"],
        ["given day is Wed, when today is Mon", "2022-03-14", 3, "2022-03-09"],
    ])
    ("should return given weekday from last week - %s", 
    (testCase, today, givenWeekday, expectedDate) => {
        expect(getRecentInstanceOfWeekday(today, givenWeekday)).toBe(expectedDate);
    })

    it.each([
        ["given day is Wed, when today is Wed (same day)", "2022-03-16", 3, "2022-03-16"],
        ["given day is Wed, when today is Thu", "2022-03-17", 3, "2022-03-16"],
        ["given day is Wed, when today is Sun", "2022-03-20", 3, "2022-03-16"],
    ])
    ("should return given weekday from current week - %s", 
    (testCase, today, givenWeekday, expectedDate) => {
        expect(getRecentInstanceOfWeekday(today, givenWeekday)).toBe(expectedDate);
    })
})

And these too will continue to pass 🎉

PASS  ./momentjs.test.js
  getRecentInstanceOfWeekday
    ✓ should return given weekday from last week - given day is Wed, when today is Tue
    ✓ should return given weekday from last week - given day is Wed, when today is Mon
    ✓ should return given weekday from current week - given day is Wed, when today is Wed (same day)
    ✓ should return given weekday from current week - given day is Wed, when today is Thu
    ✓ should return given weekday from current week - given day is Wed, when today is Sat

Conclusion

Working with Date and DateTime objects is a pain in the ass even with libraries that help you to deal with them — but it gets better! Once you know that certain gotchas exist and you have more experience with interesting use cases, you will know the edge cases to look out for. Translating requirements into tests will also help you build more confidence in the functionalities that you are delivering ✨

Below are some additional resources if you are keen to explore further.

Resources

That's a wrap folks! 🎉

https://c.tenor.com/eoM1uCVuXtkAAAAM/yay-excited.gif

Thank you for reading, hope you enjoyed the article!

✨ Special thanks to Shaun Chiang & Kah Yee Kwa for pairing with me to work on interesting DateTime scenarios.

If you find the article awesome, hit the reactions 🧡 and share it 🐦~

To stay updated whenever I post new stuff, follow me on Twitter.

Did you find this article valuable?

Support Estee Tey by becoming a sponsor. Any amount is appreciated!