Date objects are nicer to work with than date strings

Published on in JavaScript, TypeScript and Zod

When parsing data, date strings should likely be converted to Date objects. But beware mutations.

Table of contents

Intended audience / Applicable languages

This post is for you if you are dealing with date strings.

This post uses JavaScript and TypeScript, but the main ideas are likely quite language-agnostic.

Context

Let's say some data from e.g. an API or database contains a date string in ISO format:

const data = {
  createdOn: '2023-06-25T01:23:45Z',
  // ...
}

When validating/parsing the data, the date string could at the same time be transformed to a Date object. (See Parsing data is nicer than only validating it.)

Example using Zod, a TypeScript validation library:

import { z } from 'zod'

const Schema = z.object({
  createdOn: z.string().datetime().pipe(z.coerce.date()),
  // ...
})

const result = Schema.parse(data)
//    ^? { createdOn: Date; ... }

Why Date objects are better

Semantically correct

A date string is not a date; it's a serialized format of a date.

When parsing data, it's semantically correct to convert/deserialize date strings to Date objects.

(Not sure if "semantically correct" is the correct term here, but anyway.)

Convert only once

If you convert a date string to a Date object right after receiving the data, you need to do the conversion only once.

This avoids shotgun parsing, or repeating and scattering date parsing here and there. (More about shotgun parsing in Parsing data is nicer than only validating it.)

toLocaleString() et al.

If you have a Date object, you can directly use Date.prototype.toLocaleString() and other Date methods.

If you have a date string, you first need to convert the string to a Date object – every time you want to use toLocaleString() (shotgun parsing).

Even if you have wrapper functions for formatting dates (probably a good thing), you could make the functions take a Date argument instead of a Date | string argument (simplicity).

date-fns et al.

date-fns is a JavaScript date utility library.

Most date-fns functions take Date arguments, not date string arguments, so you can do e.g. just startOfWeek(date) instead of startOfWeek(parseISO(dateString)) (shotgun parsing, again).

Beware mutations

One drawback is that Date objects are mutable. Accidental mutations can lead to hard-to-track bugs; deliberate mutations can lead to hard-to-reason code.

This might be not such a big issue if the object is not otherwise guarded; obj.createdOn.setFullYear(2000) might not be much worse than any of these:

  • obj.createdOn = '2000' + obj.createdOn.slice(4)
  • obj.name = 'foo'
  • obj.items.push('bar')

Anyhow, there are a few ways to avoid Date mutations (these are not specific to Zod):

Use a function

Instead of obj.createdOn, make it a function obj.getCreatedOn() that creates a new Date object every time:

const obj = {
  getCreatedOn: () => new Date('2023-06-25T01:23:45Z'),
}

const a = obj.getCreatedOn()
const b = obj.getCreatedOn()

a === b //=> false

Good: If you mutate the Date object returned by obj.getCreatedOn(), the mutation affects only the freshly-created Date object. That's better than the mutation affecting who knows how many places.

Bad: Some object properties are now functions and some are not, making the object asymmetrical (obj.name vs obj.getCreatedOn()).

Use a getter property

Very similar to above, but make obj.createdOn a getter property instead:

const obj = {
  get createdOn() {
    return new Date('2023-06-25T01:23:45Z')
  },
}

const a = obj.createdOn
const b = obj.createdOn

a === b //=> false

Good: Same as above + now the object is more symmetrical (obj.name vs obj.createdOn).

Bad: I don't like getters and setters because they are not transparent; they look like normal object properties, but they are not.

Use TypeScript

TypeScript doesn't have a built-in type for read-only Dates, but such a type can be easily created:

type ReadonlyDate = Readonly<
  Omit<
    Date,
    | 'setDate'
    | 'setFullYear'
    | 'setHours'
    | 'setMilliseconds'
    | 'setMinutes'
    | 'setMonth'
    | 'setSeconds'
    | 'setTime'
    | 'setUTCDate'
    | 'setUTCFullYear'
    | 'setUTCHours'
    | 'setUTCMilliseconds'
    | 'setUTCMinutes'
    | 'setUTCMonth'
    | 'setUTCSeconds'
  >
>

(Credit: Daniel Nixon; the type is from date-fns GitHub issue Suggestion: use a readonly Date type in TypeScript. Also, using Readonly<...> is probably unnecessary here.)

Good: Leverages the type system; doesn't affect runtime.

Bad: E.g. date-fns functions don't accept ReadonlyDate arguments:

Argument of type ReadonlyDate is not assignable to parameter of type number | Date.

Type ReadonlyDate is missing the following properties from type Date: setTime, setMilliseconds, setUTCMilliseconds, setSeconds, and 11 more.

Use ESLint

Date mutations could be theoretically banned with ESLint.

eslint-plugin-better-dates does this, but the plugin looks outdated. It would probably be quite easy to create a similar plugin from scratch.

Good: Sounds like a job suitable for ESLint.

Bad: The plugin looks outdated.

Using ESLint might be my choice to avoid Date mutations.

Summary

  • A date string is a serialized format of a date.
  • When parsing data, it's "correct" to deserialize date strings to Date objects.
  • Correctness aside, Date objects are also nicer to work with.
  • Beware accidental mutations of Date objects.