1 Comment

DateStr – A Strongly-Typed Date String for 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.