Editor’s note: See a May 2022 update to this post here: Updated “Branding” for a Strongly-Typed Date String in TypeScript
Nearly every project I have ever worked on has had to deal with dates in one way or another. For example, there might be a need to generate a report for a specific date range. This would require a user to select a start and end date, which would be sent over the network to an API. The API would use those dates in an SQL query to retrieve the correct results.
Each line in the resulting report would likely contain at least one date that would need to be formatted for display, and a user would probably want to be able to sort the report based on those dates.
This kind of thing comes up all the time. When a technology can distinguish between something that’s just a date and something that has both a date and a time component, working with dates is not so bad. For example, PostgreSQL has a date
type, a timestamp
type (date and time), and a time
type.
But when a language/technology only supports a combined date and time, it can be easy to get tripped up. Unfortunately, JavaScript, and therefore TypeScript, fall into that category, having only the native Date
object.
The Problem with a Date Object
From the MDN Reference:
Date objects are based on a time value that is the number of milliseconds since 1 January, 1970 UTC.
This means that a Date
is really a date and a time. Even if you create the object from something that’s just a date, like a PostgreSQL date
column, or by parsing a string like "2017-05-30"
from some JSON, there’s still going to be a time component (usually set to midnight local time, which can even cross into a different date depending on the offset from local time to GMT).
Another problem I always have with the JavaScript Date object is that its constructor, which takes a year, month, and day, expects the month to be 0-based. (For example, February would be month 1
.) This always causes issues when I’m trying to create specific dates in tests.
Represent a Date with a String
Whenever possible, I try to avoid using the Date
object, using a string representation in the format of: YYYY-MM-DD
instead. This works remarkably well for several reasons:
- You get the equality you’d expect when comparing two date strings to see if they are the same date.
- The strings can be sorted alphanumerically, so you get sortability for free.
- SQL understands this format when it comes to storing/retrieving dates from the database.
- No conversion is necessary when passing data back and forth using a JSON API.
When you’re using TypeScript, you can improve on this by using the type system to ensure that you’re not passing around just any old string, but only something that truly is a date string, in the desired format.
DateStr – A Strongly Typed Date String
The goal is to come up with a type that’s both a string
(so it can be used anywhere a string
can be used), but also distinct in some way. This allows the type system to make sure that only a DateStr
is being passed around when that’s what is required.
Using a technique I learned from Drew Colthorp that combines Nominal Typing (using enums) and Type Guards, it’s possible to create just such a DateStr
type.
enum DateStrBrand { }
export type DateStr = string & DateStrBrand;
From the “Using Enums” discussion in the Nominal Typing section of the TypeScript Deep Dive book:
Enums in TypeScript offer a certain level of nominal typing. Two enum types aren’t equal if they differ by name. We can use this fact to provide nominal typing for types that are otherwise structurally compatible.
In our case, that means the DateStr
is structurally compatible with other strings
(it can be passed to functions that require a string
), but a plain string
can’t be passed to a function that requires a DateStr
because it won’t have the “brand.”
Also note that the DateStrBrand
isn’t exported. This means it’s not possible for other modules to use it, ensuring there won’t be counterfeit DateStr
objects being manufactured.
But we’re still missing an important piece here–how do you get a DateStr
object?
function checkValidDateStr(str: string): str is DateStr {
return str.match(/^\d{4}-\d{2}-\d{2}$/) !== null;
}
export function toDateStr(date: Date | moment.Moment | string): DateStr {
if (typeof date === 'string') {
if (checkValidDateStr(date)) {
return date;
} else {
throw new Error(`Invalid date string: ${date}`);
}
} else {
const dateString = moment(date).format('YYYY-MM-DD');
if (checkValidDateStr(dateString)) {
return dateString;
}
}
throw new Error(`Shouldn't get here (invalid toDateStr provided): ${date}`);
}
The toDateStr
function is the factory for DateStr
objects. You can pass in a Date
, a Moment
(from Moment.js), or a string
in the format of YYYY-MM-DD
, and you’ll get back a DateStr
object, which is really just a blessed string
.
It makes use of a Type Guard function, checkValidDateStr
, to tell TypeScript that a valid string
is in fact a DateStr
as long as it matches a certain regular expression. (Unfortunately, it can only check at run time.)
It also has logic for converting from either a native Date
object or a Moment.js Moment
object. This is important because you’ll often end up with Moment
objects if you’re doing any kind of date math or integrating with third-party libraries, and you’ll need to be able to convert back to a DateStr
when done.
// Examples
const today = toDateStr(new Date());
const fourthOfJuly = toDateStr('2017-07-04');
fourthOfJuly === '2017-07-04'; // true
function formatDate(date: DateStr) {
return moment(date).format('MM/DD/YY');
}
formatDate('foo'); // compile error
formatDate(toDateStr('foo')); // runtime error
formatDate(toDateStr('2017-07-04')); // 07/04/17
Conclusion
A string in the format of YYYY-MM-DD
is a great representation of a date. But just using a string to represent your dates strips away some of the type safety provided by a language like TypeScript. Fortunately, TypeScript is powerful enough to allow for a type to be both a string, and something more specific–in this case, a well formatted string representation of a date.
Checking that the string matches the regular expression is a good start, but it still allows invalid dates to be passed, like “2017-13-99”. To prevent this, extract the year, month and day, and check if month is between 1 and 12, and if day is between 1 and the number of days in the month of that year.
Good suggestion. Thanks Tommy!
Idiomatically:
export function isValidIsoDate(dateString: string) {
const bits = dateString.split(“-“);
const d = new Date(Number(bits[0]), Number(bits[1]) – 1, Number(bits[2]));
return d && (d.getMonth() + 1) === Number(bits[1]) && d.getDate() === Number(bits[2]);
}
I like this suggestion and would like to use it, infact I have a few other string types which I would like to add type safety too (SHA256 etc).
Where’s the source code and what’s the license?
Matt,
All of the source code is included in this blog post (I haven’t put it all together in a separate repo or gist at this point). I hadn’t really thought about a license for it, but if you want to use it you can consider released under the MIT License.
Thanks :-)
Ah someone who uses Date-Strings in the same way that I do and even the format is the same :) .
Bringing type safety to Date-strings in this way is nice.
And thanks for the clear language and the idea for nominal typing with enums!