Skip to content

Nanosecond Precision

  • The core functions, which every industry uses.

convertZonedToUnix and its siblings return number milliseconds. Systems that record telemetry, trades or sensor readings need nanoseconds, and a number cannot hold them. Integers are exact only to 2^53 − 1, about 9.0 × 10^15, and nanoseconds since the epoch passed that in April 1970.

Date.now() * 1e6 is not a nanosecond timestamp. It is a millisecond timestamp with three zeroes appended.

The precision/ namespace works in bigint:

import {
toNanoseconds,
fromNanoseconds,
formatNanoseconds,
parseNanoseconds,
truncateNanoseconds,
} from "@northguild/gmt";
toNanoseconds("2024-03-10T12:00:00.123456789Z");
// 1710072000123456789n
fromNanoseconds(1710072000123456789n);
// "2024-03-10T12:00:00.123456789Z"
fromNanoseconds(1710072000123456789n, "America/New_York");
// "2024-03-10T08:00:00.123456789-04:00[America/New_York]"

JSON.stringify throws a TypeError on a bigint, so the value crosses a wire as a canonical decimal string:

JSON.stringify({ observedAt: formatNanoseconds(1710072000123456789n) });
// '{"observedAt":"1710072000123456789"}'
parseNanoseconds("1710072000123456789");
// 1710072000123456789n

Most storage engines cannot hold nanoseconds. PostgreSQL timestamptz and MySQL DATETIME(6) hold microseconds, so a naive write-then-read loses the last digits silently. Truncate to the target precision first, and the round trip is exact:

truncateNanoseconds(1710072000123456789n, "us");
// 1710072000123456000n — safe to write to a microsecond column
truncateNanoseconds(1710072000123456789n, "ms");
// 1710072000123000000n

Truncation floors toward negative infinity, so pre-1970 values truncate the same way post-1970 ones do. truncateNanoseconds(-1500n, "us") is -2000n, not -1000n. Rounding toward zero would make the result jump direction on either side of the epoch.

0n is both the epoch and the invalid-input sentinel, so precision/validate exists to tell them apart. Each predicate accepts exactly what its partner parses:

import {
isValidInstant,
isValidNanoseconds,
isValidNanoPattern,
} from "@northguild/gmt";
isValidInstant("1970-01-01T00:00:00Z"); // true — toNanoseconds returns 0n, the epoch
isValidInstant("garbage"); // false — toNanoseconds returns 0n, the sentinel
isValidNanoPattern("0"); // true — parseNanoseconds returns 0n, the epoch
isValidNanoPattern("1e18"); // false
isValidNanoseconds(0n); // true — in range for truncateNanoseconds / fromNanoseconds
isValidNanoseconds(0); // false — a number cannot carry a nanosecond timestamp

Reach for isValidInstant rather than isValidUtc. The latter gates on the library’s stricter <date>T<time>Z shape and rejects the offsets and bracketed zones that toNanoseconds accepts, so validating with it discards valid input.

Every precision/ function returns a sentinel ("" for strings, 0n for bigints) on invalid input. It accepts only values inside the range Temporal.Instant can represent, ±8_640_000_000_000_000_000_000n. 0n is also the epoch itself, so validate the input first when the two must be told apart.

Leap-second-aware time scales (TAI, GPS) are not part of this namespace. These are plain instant conversions.

A zoned string names one instant in every reader

Section titled “A zoned string names one instant in every reader”

Temporal writes a zone’s offset rounded to the minute. A zone with a sub-minute offset is therefore written with an offset up to 30 seconds from the real one. Africa/Monrovia stood at −00:44:30 until 1972 and is written -00:45.

Every function in the library that reads a moment reads such a string as Temporal.ZonedDateTime.from does. TC39 ToTemporalZonedDateTime matches a minute-precision offset by minutes. A zoned string the library wrote therefore reads back as the instant it was written for:

import { isValidZonedDateTime, toNanoseconds } from "@northguild/gmt";
toNanoseconds("1960-01-01T00:20:00-00:45[Africa/Monrovia]"); // -315615330000000000n (01:04:30Z)
toNanoseconds("1960-01-01T00:20:00-00:45"); // -315615300000000000n (01:05:00Z; no zone, so the offset is exact)
toNanoseconds("2024-06-15T10:00:00Z[Not/AZone]"); // 1718445600000000000n (an instant reader does not validate the bracket)
isValidZonedDateTime("1960-01-01T00:20:00-00:45[Africa/Monrovia]"); // true (the rounded offset)
isValidZonedDateTime("1960-01-01T00:20:00-00:44:30[Africa/Monrovia]"); // true (the exact offset)
isValidZonedDateTime("1960-01-01T00:20:00-00:45:00[Africa/Monrovia]"); // false (an offset with seconds must be exact)
  • Where the rule applies. It applies to precision/, span/, interval/, calendar/, intermodal/, transport/ and getTimeZoneOffset alike. Any other string keeps its written offset: one without a bracket, a Z instant, an offset written with seconds, and a bracket whose zone does not exist or does not fit the offset.
  • Every string Temporal writes for an instant in range is valid, the first and last instants included. -271821-04-19T23:58:45-00:01[Europe/London] is the first instant.
  • Functions that keep the zone are stricter. isValidZonedDateTime, transitTime, toOffsetInstant, every zoned/ function and a zoned relativeTo reject a bracket that contradicts the offset. They require an offset written with seconds to be the zone’s offset exactly.
  • Two limits are Temporal’s own. A wall time repeated inside a sub-minute offset change, written to the minute, reads as its first pass (1952-10-15T23:59:59-11:20[Pacific/Niue]). Write the offset with seconds (-11:20:00) to name the second. A zoned read also refuses a local date of −271821-04-19, which a zone west of Greenwich shows for the first hours of the instant range. Pass that instant in Z form.