DateStr – A Strongly-Typed Date String for TypeScript

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.

Conversation
  • 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.

    • Patrick Bacon Patrick Bacon says:

      Good suggestion. Thanks Tommy!

    • Benoit BENEZECH says:

      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?

    • Patrick Bacon Patrick Bacon says:

      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.

  • 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!

  • Comments are closed.