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 Date
s,
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 typenumber | Date
.Type
ReadonlyDate
is missing the following properties from typeDate
: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.