Dates & Time
Dates & Time

Parsing & formatting dates

Lesson · ~30 min
After this unit you can
  • Predict which instant new Date(string) builds from each ISO shape: date-only is UTC midnight, a date-time without an offset is local, Z or an offset is exact
  • Keep non-ISO strings ("10/03/2026") away from Date parsing, parse a known format by hand, and read <input type="date"> values as date-only strings
  • Format for people with Intl.DateTimeFormat (dateStyle/timeStyle, timeZone), and build machine strings with toISOString() or formatToParts instead of slicing localized output
  • Write relative times with Intl.RelativeTimeFormat, computing the value and unit yourself (numeric: "auto" gives "yesterday")

Read

Simple
1 / 2

Three ISO shapes, three different instants

Flag

new Date(string) reliably parses one family of strings: the ECMAScript date time string format, a simplified ISO 8601 (YYYY-MM-DDTHH:mm:ss.sssZ). Inside that family, whether there is an offset decides which instant you get:

// machine in America/New_York
new Date("2026-03-10");             // date-only            → UTC midnight      (2026-03-10T00:00Z)
new Date("2026-03-10T00:00");       // date-time, no offset → LOCAL midnight    (2026-03-10T04:00Z)
new Date("2026-03-10T00:00Z");      // Z                    → exactly that      (2026-03-10T00:00Z)
new Date("2026-03-10T00:00+05:30"); // offset               → exactly that      (2026-03-09T18:30Z)

The spec's rule: when the offset is absent, date-only forms are UTC and date-time forms are local. MDN calls the UTC reading of date-only strings "a historical spec error" that couldn't be fixed without breaking the web. It's why this prints the 9th in New York:

new Date("2026-03-10").getDate(); // 9: UTC midnight is 8 p.m. on March 9 in New York

Two rules follow:

  • An instant on the wire always carries Z or an offset. toISOString() produces exactly that, which is why it's the format for APIs, logs and sorting.
  • A date-only value is not an instant. Don't hand "2026-03-10" to new Date and then read local getters. Keep it as a string, or do date math in UTC (the next unit is about exactly this).
Medium
1 / 2

Parsing what users and other systems actually send

Flag

Outside the ISO family, the spec lets each engine "fall back to any implementation-specific heuristics". So new Date("03/10/2026") is a guess, and the guess is American:

new Date("10/03/2026"); // V8 (Chrome, Node): October 3, 2026
// A UK user typed 10 March. Formatting can't fix a wrong parse.

MDN's advice is to make sure input conforms to the standard format, "because support for other formats is not guaranteed". When you know the format, parse it yourself, into numbers:

// A CSV from a UK client: DD/MM/YYYY
function parseUkDate(s) {
  const m = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(s);
  if (!m) throw new RangeError(`expected DD/MM/YYYY, got ${s}`);
  const [, dd, mm, yyyy] = m;
  return `${yyyy}-${mm}-${dd}`; // "2026-03-10": a date-only value, still unchecked
}

The regex only checks the shape: 31/02/2026 gets through. Checking that the date exists is the next unit's job.

Form inputs already give you ISO strings

  • <input type="date">: value is always "yyyy-mm-dd", whatever the display looks like (10.03.2026 for a German user). That's a date-only string; keep it one. Its valueAsDate is a Date at midnight UTC (the HTML spec says so), so input.valueAsDate.getDate() shows the previous day anywhere west of UTC.
  • <input type="datetime-local">: value is "YYYY-MM-DDTHH:mm", with no zone. In the browser, new Date(value) reads it as the user's local time, which is usually what they meant. Convert there and send the instant:
const instant = new Date(form.startsAt.value);  // in the browser: the user's zone
await save({ startsAt: instant.toISOString() }); // New York user: "2026-03-10T13:30:00.000Z"

Sending the raw "2026-03-10T09:30" instead makes the server parse it in its zone. MDN also notes the control has no way to set a zone. If the time belongs to a different place than the user's device (an event in another city), collect the zone separately and convert with it.

new Date(value) never refuses a time, even one the clock skipped. On March 8, 2026, "2026-03-08T02:30" doesn't exist in New York and becomes 03:30; on November 1, 01:30 happens twice and you get the first. For a booking or a reminder, check that the instant still reads back as the time the user picked, and ask when it doesn't. The date-only-and-dst unit covers both.

Complex

Strings that parse when they shouldn't, formatters that throw

Flag

An impossible date inside a valid shape. The format allows a day from 01 to 31 in any month, so "2026-02-30" matches it. What happens next is up to the engine: MDN notes that for a string whose components are in bounds but don't name a real date, like February 30, implementations "behave inconsistently". V8 (Chrome, Node) rolls it forward:

new Date("2026-02-30").toISOString(); // V8: "2026-03-02T00:00:00.000Z"; other engines may give Invalid Date
new Date("2026-13-01").getTime();     // NaN everywhere: the spec requires NaN for an out-of-bounds month

So parsing succeeding doesn't mean the date exists. Validate the components yourself.

A space instead of T. "2026-03-10 09:00" is not in the standard format, so it's implementation-defined. V8 reads it as local time, but don't rely on that. The same goes for microseconds, two-digit offsets like +00 and other shapes databases and logs print: normalize them to the standard format at the edge, or have the API send toISOString() output.

Two invalid-date behaviours. Formatting an Invalid Date is not consistent across APIs:

const bad = new Date("not a date");
bad.toLocaleDateString();                      // "Invalid Date" (a string, no error)
new Intl.DateTimeFormat("en-US").format(bad);  // throws RangeError: Invalid time value

The first renders "Invalid Date" in your UI; the second crashes the render. Both are cured by validating at the parse.

Formatter options that throw. The constructor validates its options:

new Intl.DateTimeFormat("en-US", { dateStyle: "medium", hour: "numeric" });
// TypeError: ECMA-402 forbids mixing a style preset with individual parts

new Intl.DateTimeFormat("en-US", { timeZone: "Mars/Olympus" });
// RangeError: invalid time zone (a typo in a stored user zone fails here)

A user's saved timeZone usually comes from the browser and is valid, but an import, an admin form or a typo can put an unknown name there. Validate it when you save it (construct a formatter once and catch the RangeError), not on every render.

Defaults come from the runtime. No locale means the runtime's default locale; no timeZone means the runtime's zone. On a server that's the server's, not the user's (the previous unit), and it's why the same toLocaleString() call renders differently in development and production.

Quiz

On a machine in New York, what does new Date("2026-03-10").getDate() return?

Flag

Which string names the same instant on every machine?

Flag

A CSV from a UK client contains 10/03/2026, meaning 10 March. How should you read it?

Flag

A German user picks 10 March 2026 in an <input type="date">, which displays 10.03.2026. What is input.value?

Flag

From an instant, you need a Tokyo user's calendar date as YYYY-MM-DD. Which code is right on any server?

Flag

What does new Intl.DateTimeFormat("en-US", { dateStyle: "medium", hour: "numeric" }) do?

Flag

new Intl.RelativeTimeFormat("en", { numeric: "auto" }).format(-1, "day") returns what, and where does the -1 come from?

Flag

Fill in the blank

Send an instant in the one format every machine parses to the same moment.

Flag
const body = JSON.stringify({ startsAt: startsAt.() });

Create one reusable formatter: a medium date and short time, on New York's wall clock.

Flag
const fmt = new Intl.DateTimeFormat("en-US", {
  : "medium",
  timeStyle: "short",
  : "America/New_York",
});
fmt.format(new Date("2026-03-10T15:00:00Z")); // "Mar 10, 2026, 11:00 AM"

Pull the year out of formatted output without depending on the locale's order or punctuation.

Flag
const parts = fmt.(instant);
const year = parts.find((p) => p. === "year").value;

Make the formatter say "yesterday" instead of "1 day ago".

Flag
new Intl.RelativeTimeFormat("en", { numeric: "" }).format(-1, "day"); // "yesterday"

Predict the output

// The machine is in America/New_York (UTC−4 on March 10, 2026).
console.log(new Date("2026-03-10").toISOString());
console.log(new Date("2026-03-10T00:00").toISOString());
console.log(new Date("2026-03-10T00:00Z").toISOString());
Flag
// Node or Chrome (the V8 engine)
const d = new Date("2026-02-30");
console.log(d.toISOString());
Flag
// Any machine, any zone.
const late = new Date("2026-03-10T23:30:00Z");
console.log(late.toISOString().slice(0, 10));
console.log(late.toLocaleDateString("en-US", { timeZone: "Asia/Tokyo" }));
Flag

Further reading