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:
parent
890222279a
commit
d83cbd98bf
|
@ -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();
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue