Create util for TimestampMs and TimestampISO (#1746)

We have a convention of using TimestampMs as our default representation.
However TimestampISO has the benefit of being human readable / writable,
so it's used for serialization and display as well.
We'll validate types at runtime, as there's a fair chance we'll use these
functions to parse data that came from a Flow `any` type (like JSON).

Fixes #1653
This commit is contained in:
Robin van Boven 2020-04-14 12:18:22 +02:00 committed by GitHub
parent 890222279a
commit d83cbd98bf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 198 additions and 0 deletions

69
src/util/timestamp.js Normal file
View File

@ -0,0 +1,69 @@
// @flow
/**
* We have a convention of using TimestampMs as our default representation.
* However TimestampISO has the benefit of being human readable / writable,
* so it's used for serialization and display as well.
* We'll validate types at runtime, as there's a fair chance we'll use these
* functions to parse data that came from a Flow `any` type (like JSON).
*/
// A timestamp representation in ms since epoch.
export opaque type TimestampMs: number = number;
// A timestamp representation in ISO 8601 format.
export opaque type TimestampISO: string = string;
/**
* Creates a TimestampISO from a TimestampMs-like input.
*
* Since much of the previous types have used `number` as a type instead of
* TimestampMs. Accepting `number` will give an easier upgrade path, rather
* than a forced refactor across the codebase.
*/
export function toISO(timestampLike: TimestampMs | number): TimestampISO {
const timestampMs: TimestampMs = fromNumber(timestampLike);
return new Date(timestampMs).toISOString();
}
/**
* Creates a TimestampMs from a TimestampISO.
*/
export function fromISO(timestampISO: TimestampISO): TimestampMs {
if (typeof timestampISO !== "string") {
throw new TypeError(
`TimestampISO values must be strings, ` +
`received: ${String(timestampISO)}`
);
}
const parsed = Date.parse(timestampISO);
if (Number.isNaN(parsed)) {
throw new RangeError(
`Could not parse TimestampISO, are you sure it's a valid ISO format? ` +
`received: ${String(timestampISO)}`
);
}
return parsed;
}
/**
* Creates a TimestampMs from a number input.
*
* Since much of the previous types have used `number` as a type instead of
* TimestampMs. Accepting `number` will give an easier upgrade path, rather
* than a forced refactor across the codebase.
*/
export function fromNumber(timestampMs: number): TimestampMs {
const asNumber = Number(timestampMs);
if (
timestampMs === null ||
timestampMs === undefined ||
!Number.isInteger(asNumber)
) {
throw new TypeError(
`Numbers representing TimestampMs values must be finite integers, ` +
`received: ${String(timestampMs)}`
);
}
return new Date(asNumber).valueOf();
}

129
src/util/timestamp.test.js Normal file
View File

@ -0,0 +1,129 @@
// @flow
import type {TimestampMs, TimestampISO} from "./timestamp";
import {fromISO, toISO, fromNumber} from "./timestamp";
/**
* Helper function to write readable "toThrow" tests. This intentionally
* ignores Flow types, as we want to test runtime input validation.
*
* Use by replacing:
* expect(() => fn(...args)).toThrow();
* With:
* given(...args).expect(fn).toThrow();
*/
function given(...args: mixed[]) {
return {
expect: (fn: Function) => expect(() => fn(...args)),
};
}
describe("util/timestamp", () => {
const fullExample = {
ISO: (("2020-01-01T12:34:56.789Z": any): TimestampISO),
ms: ((1577882096789: any): TimestampMs),
};
const partialExample = {
ISO: (("2020-01-01": any): TimestampISO),
fullISO: (("2020-01-01T00:00:00.000Z": any): TimestampISO),
ms: ((1577836800000: any): TimestampMs),
};
// These double as a Flow sanity check.
describe("roundtrips", () => {
it("should handle number roundtrips", () => {
const origin: number = fullExample.ms;
expect(fromNumber(origin)).toBe(origin);
expect(fromISO(toISO(origin))).toBe(origin);
expect(fromNumber(fromISO(toISO(origin)))).toBe(origin);
});
it("should handle ISO roundtrips", () => {
const origin: TimestampISO = fullExample.ISO;
expect(toISO(fromISO(origin))).toBe(origin);
expect(toISO(fromNumber(fromISO(origin)))).toBe(origin);
});
});
describe("toISO", () => {
it("should throw on null", () => {
given(null).expect(toISO).toThrow(TypeError);
});
it("should throw on undefined", () => {
given(undefined).expect(toISO).toThrow(TypeError);
});
it("should throw on NaN", () => {
given(NaN).expect(toISO).toThrow(TypeError);
});
it("should throw on Infinity", () => {
given(Infinity).expect(toISO).toThrow(TypeError);
});
it("should throw on ISO strings", () => {
given("2020-01-01T12:34:56.789Z").expect(toISO).toThrow(TypeError);
});
it("should handle 0 correctly", () => {
expect(toISO(0)).toBe("1970-01-01T00:00:00.000Z");
});
it("should handle Date.now correctly", () => {
const now = Date.now();
const nowISO = new Date(now).toISOString();
expect(toISO(now)).toBe(nowISO);
});
it("should handle examples correctly", () => {
expect(toISO(fullExample.ms)).toBe(fullExample.ISO);
expect(toISO(partialExample.ms)).toBe(partialExample.fullISO);
});
});
describe("fromISO", () => {
it("should throw on null", () => {
given(null).expect(fromISO).toThrow(TypeError);
});
it("should throw on undefined", () => {
given(undefined).expect(fromISO).toThrow(TypeError);
});
it("should throw on numbers", () => {
given(123).expect(fromISO).toThrow(TypeError);
});
it("should handle examples correctly", () => {
expect(fromISO(fullExample.ISO)).toBe(fullExample.ms);
expect(fromISO(partialExample.ISO)).toBe(partialExample.ms);
});
it("should throw on invalid formatting", () => {
given("04/31/2016 12:46pm").expect(fromISO).toThrow(RangeError);
});
it("should throw on illegal date", () => {
given("2000-02-32T00:00:00.000Z").expect(fromISO).toThrow(RangeError);
});
});
describe("fromNumber", () => {
it("should throw on null", () => {
given(null).expect(fromNumber).toThrow(TypeError);
});
it("should throw on undefined", () => {
given(undefined).expect(fromNumber).toThrow(TypeError);
});
it("should throw on NaN", () => {
given(NaN).expect(fromNumber).toThrow(TypeError);
});
it("should throw on Infinity", () => {
given(Infinity).expect(fromNumber).toThrow(TypeError);
});
it("should throw on floating point numbers", () => {
given(1 / 3)
.expect(fromNumber)
.toThrow(TypeError);
});
it("should handle 0 correctly", () => {
expect(fromNumber(0)).toBe(0);
});
it("should handle Date.now correctly", () => {
const now = Date.now();
expect(fromNumber(now)).toBe(now);
});
it("should handle examples correctly", () => {
expect(fromNumber(fullExample.ms)).toBe(fullExample.ms);
expect(fromNumber(partialExample.ms)).toBe(partialExample.ms);
});
});
});