Taming Timezones in JS: extracting TZ offset
Part 2 of timezone-aware parsing in JavaScript with @date-fns/tz, the native JS Date() constructor and a little bit of magic dust.
JavaScript Date-time ISO 8601 format
If you've worked on JavaScript systems that handle date times, they're often serialised as 2020-10-14T14:03:00+0200 which is what toISOString() outputs. The format is a "simplified format based on ISO 8601" (see MDN toISOString()).
You can get quite far parsing specific parts of JS-ISO-formatted date strings using .indexOf(char)/.lastIndexOf(char).
I'm not going to get into the weeds of the format because we will only parse a few relatively well-known shapes of strings and don't need to implement a full parser (such as date-fns#parseISO or moment()). For a post about writing a full parse, you should read Never write your own Date Parsing Library.
Our goal is to use @date-fns/tz#TZDate which is an augmented Date object which takes an additional parameter: it's called with new TZDate(initializer, tzOffset). TZDate doesn't do any parsing beyond calling new Date(...) under the hood, so we need to compute the tzOffset ourselves.
Our function needs to convert date strings to their timezone offset defaulting to UTC if no offset found, for example:
"2020-10-14T14:03:00+0200"->"+0200""2020-10-14T14:03:00-0200"->"-0200""2020-10-14T14:03:00Z"->"UTC""2020-10-14T14:03:00"->"UTC""2020-10-14"->"UTC"
In keeping with the moment.parseZone() behaviour, we'll keep the API permissive with regards to "bad" inputs:
""->"UTC"undefined->"UTC"
Here's the full code, much easier to read than thedate-fns#parseISO implementation of TZ extraction where the logic is in the regex: /([Z+-].*)$/
function getISOTimezoneOffset(dateStr: string): string {
if (!dateStr) {
return 'UTC';
}
if (!dateStr.includes('T')) {
return 'UTC';
}
const isDateUTC = dateStr.at(-1) === 'Z';
if (isDateUTC) {
return 'UTC';
}
const positiveTzOffsetStartIndex = dateStr.lastIndexOf('+');
if (positiveTzOffsetStartIndex > -1) {
return dateStr.slice(positiveTzOffsetStartIndex);
}
const negativeTzOffsetStartIndex = dateStr.lastIndexOf('-');
const lastColonCharacterIndex = dateStr.lastIndexOf(':');
if (
negativeTzOffsetStartIndex > -1 &&
lastColonCharacterIndex > -1 &&
negativeTzOffsetStartIndex > lastColonCharacterIndex
) {
return dateStr.slice(negativeTzOffsetStartIndex);
}
return 'UTC';
}
This utility can be used as follows to rely on new Date(dateStr) parsing (that's what @date-fns/tz uses under the hood), but also set the timezone to what's inside the ISO string (which @date-fns/tz does not do under the hood).
The bulk of the logic is: new TZDate(dateStr, getISOTimezoneOffset(dateStr));.
We'll get into the rest of the parseZone-equivalent wrapper next time but you can read the annotated source: github.com/HugoDF/real-world-ts/blob/main/src/parse-date-in-tz.ts.