Skip to content
APIs

9 JSON Date Tests Before Releasing an API

·12 min read
9 JSON Date Tests Before Releasing an API - APIs guide from JSON Prettifier

Quick answer: Before releasing a JSON API, test whether each temporal field represents a calendar date or instant, rejects impossible dates, requires an offset, preserves equivalent instants and agreed precision, distinguishes epoch units, normalizes UTC deliberately, handles invalid Date objects, and survives a complete serialize-parse round trip.

JSON carries dates only by convention, usually as strings or numbers. Valid JSON can still contain an impossible day, an ambiguous local time, or a timestamp interpreted one thousand times too large. These nine executable tests turn the API's temporal assumptions into release checks.

Wikipedia's JSON overview explains the format's value types and why dates need an application-level representation. Technical behavior below comes from RFC 3339, JSON Schema, ECMAScript documentation, and executable assertions rather than that background page alone.

1. Does each field distinguish a calendar date from an instant?

A calendar date such as a birthday is not an instant, while an event timestamp identifies one point on the timeline. Test their shapes separately: YYYY-MM-DD for the date and an offset-bearing date-time for the instant. Converting both into JavaScript Date objects can shift the calendar value for users west of UTC.

Birth dates, billing days, and checkout dates usually remain the same label across timezones. Creation timestamps must identify the same instant everywhere. A generic field named date hides that difference and encourages each client to guess.

import assert from "node:assert/strict";
const value = { birthday: "1990-06-15", createdAt: "2026-10-21T09:30:00Z" };
const date = /^\d{4}-\d{2}-\d{2}$/;
const instant = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
assert(date.test(value.birthday));
assert(instant.test(value.createdAt));
assert(!instant.test(value.birthday));

Use names that expose intent: birthDate, serviceDay, createdAt, and expiresAt. If a local date-time without an offset is allowed, pair it with a named IANA timezone and document how daylight-saving overlaps and gaps are resolved.

Keep plain dates as strings unless the client has a genuine date-only type. The JSON date format guide covers representation choices; this checklist tests that the chosen contract survives implementation.

2. Does strict validation reject impossible calendar dates?

A shape check alone accepts values such as February 30. Test syntax and calendar validity independently by parsing numeric components and reconstructing them in UTC. Do not rely only on Date.parse, because JavaScript may normalize some out-of-range components instead of rejecting the original calendar date received by the API.

The date format in the JSON Schema validation specification follows RFC 3339 full-date. Configure the validator to assert formats, then add application rules such as minimum age or supported reporting range.

import assert from "node:assert/strict";
function isFullDate(value) {
  const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
  if (!match) return false;
  const [, y, m, d] = match.map(Number);
  const parsed = new Date(Date.UTC(y, m - 1, d));
  return parsed.getUTCFullYear() === y && parsed.getUTCMonth() === m - 1 &&
    parsed.getUTCDate() === d;
}
assert.equal(isFullDate("2024-02-29"), true);
assert.equal(isFullDate("2026-02-29"), false);
assert.equal(isFullDate("2026-02-30"), false);

Include leap years, month ends, year boundaries, and missing zero padding. Syntax validity is not business validity: a correctly formed historical date may still violate an endpoint's range. Return distinct errors for malformed text and disallowed values.

Expanded years need a separate contract. A runtime, validator, and database can support different ranges even though all accept ordinary modern dates. Test the narrowest production component rather than assuming JavaScript's range defines the service.

3. Does every instant include Z or a numeric offset?

An API instant should include Z or an explicit ±HH:MM offset. Test that an otherwise plausible date-time without an offset is rejected. JavaScript interprets offset-free date-times in the machine's local timezone, so identical JSON can identify different instants in development, continuous integration, and production.

MDN documents that an offset-free date-only form is interpreted as UTC, while an offset-free date-time is local time. Deployment geography must not become data semantics.

import assert from "node:assert/strict";
const required =
  /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
assert(required.test("2026-10-21T15:00:00Z"));
assert(required.test("2026-10-21T20:30:00+05:30"));
assert(!required.test("2026-10-21T15:00:00"));

This expression is only an offset-presence gate; it does not prove calendar or offset validity. Use a standards-aware parser for full conformance. Keep the focused assertion because it explains the API rule clearly in a regression failure.

If the domain needs floating local time, send a separate timezone identifier and specify conflict handling. Appending Z to local components is not a fix: it asserts a different instant.

4. Do different offsets resolve to the same intended instant?

Two RFC 3339 strings with different offsets can represent the same instant. Test numeric timestamps for equality while preserving the original string only when its offset has business meaning. Comparing raw strings rejects equivalent instants, while discarding context can lose information needed for schedules or audits.

RFC 3339 permits numeric offsets and UTC Z. Normalization is useful, but it remains a policy. A calendar may need the instant and organizer's timezone; an audit event may need only the instant.

import assert from "node:assert/strict";
const utc = "2026-10-21T09:30:00Z";
const india = "2026-10-21T15:00:00+05:30";
const newYork = "2026-10-21T05:30:00-04:00";
assert.equal(Date.parse(utc), Date.parse(india));
assert.equal(Date.parse(utc), Date.parse(newYork));
assert.notEqual(utc, india);

Sort and deduplicate instants using a canonical numeric or UTC value, not lexicographic order of mixed-offset strings. Keep the original separately if its offset is evidence supplied by the caller.

Include half-hour and quarter-hour offsets in fixtures. A fixed offset is also not a named timezone: +05:30 contains no future rules, historical transitions, or geographic identity.

Add a comparison fixture that crosses midnight in one offset but not another. The instant can be equal while the displayed local calendar day differs. That is expected for an instant and potentially destructive for a service date. Testing both values prevents a later presentation-layer conversion from being reused as an API equality rule.

5. Does the API preserve its declared fractional-second precision?

Declare and test fractional-second precision explicitly. JavaScript Date stores milliseconds, while an RFC 3339 timestamp may contain more fractional digits. Parsing and re-serializing a microsecond or nanosecond value through Date truncates information. Clients must know whether the API preserves, rounds, truncates, or rejects excess precision.

A timestamp can parse successfully and return with changed text. Precision loss matters for event ordering, optimistic concurrency, trace correlation, and database round trips.

import assert from "node:assert/strict";
const source = "2026-10-21T09:30:00.123456Z";
const result = new Date(source).toISOString();
assert.equal(result, "2026-10-21T09:30:00.123Z");
assert.notEqual(result, source);
const milliseconds = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
assert(milliseconds.test(result));

If the service promises microseconds, retain validated text or use a type that preserves six digits. If it promises milliseconds, reject or deliberately round greater precision rather than losing it as an undocumented side effect.

Test values near a second boundary. Truncating .999999 differs from rounding into the next second. The database adapter test must prove that writes and reads follow the declared rule.

Compare the database value before and after a real persistence round trip, not only an in-memory conversion. Drivers may trim trailing zeros, change the number of fractional digits, or map a high-precision column into a millisecond runtime type. An integration fixture makes that boundary visible without claiming every storage engine behaves alike.

6. Does a numeric timestamp enforce seconds versus milliseconds?

A bare epoch number does not reveal whether it uses seconds or milliseconds. Test a fixed known instant, field naming, integer constraints, and an accepted range. Multiplying or dividing by one thousand at the wrong boundary can produce dates near 1970 or thousands of years away without breaking JSON syntax.

Put the unit in the field name, such as createdAtEpochMs. Avoid magnitude guessing because historical values and future ranges undermine it. Schema limits are useful as a second defense.

import assert from "node:assert/strict";
const iso = "2026-10-21T09:30:00.000Z";
const epochMs = Date.parse(iso);
const epochSeconds = epochMs / 1000;
assert.equal(epochMs, 1792575000000);
assert.equal(epochSeconds, 1792575000);
assert.equal(new Date(epochMs).toISOString(), iso);
assert.equal(new Date(epochSeconds * 1000).toISOString(), iso);

Test negative timestamps when pre-1970 dates are supported and reject them otherwise. Define whether fractional seconds are allowed and what maximum range the receiver can store. JavaScript Number, a 32-bit column, and another language's library may differ.

Strings are often easier to debug in public APIs, while epochs suit established protocols. Either can work; ambiguity cannot. Never change units under the same field between versions.

7. Does Date serialization normalize to UTC deliberately?

JavaScript Date serialization preserves the instant but emits UTC, not the original offset text. Test exact JSON.stringify output so clients do not mistake normalization for a changed moment. If the original timezone or wall-clock context matters, send it in a separate, explicitly named field.

MDN's Date.toJSON documentation says the method returns the same ISO format as toISOString. That output ends in Z; Date retains no original timezone identifier.

import assert from "node:assert/strict";
const input = "2026-10-21T15:00:00+05:30";
const value = new Date(input);
const text = JSON.stringify({ startsAt: value });
assert.equal(value.toISOString(), "2026-10-21T09:30:00.000Z");
assert.equal(text, '{"startsAt":"2026-10-21T09:30:00.000Z"}');
assert.equal(Date.parse(input), Date.parse(JSON.parse(text).startsAt));

UTC output fits an instant but not a calendar date or floating appointment. Test the actual response mapping layer because custom toJSON methods, replacers, framework transformers, and database drivers can alter output.

The JSON.stringify replacer tests cover callback order when custom serialization participates. Make UTC normalization a reviewed contract choice, not an accidental runtime default.

8. Does an invalid Date become null instead of a timestamp?

An invalid JavaScript Date serializes to JSON null through toJSON, even though calling toISOString directly throws a RangeError. Test the complete object output and reject invalid dates before serialization. Otherwise, a calculation or parsing failure can silently become a valid JSON response containing an unintended null field.

This behavior is risky when null already means “not scheduled.” A technical conversion failure should not be indistinguishable from intentional absence.

import assert from "node:assert/strict";
const invalid = new Date("not-a-date");
assert.equal(Number.isNaN(invalid.getTime()), true);
assert.throws(() => invalid.toISOString(), RangeError);
assert.equal(invalid.toJSON(), null);
assert.equal(JSON.stringify({ startsAt: invalid }), '{"startsAt":null}');

Check Number.isNaN(date.getTime()) immediately after construction or calculation. Return a client error for bad input or an internal error for corrupt stored data. JSON generation will not expose the problem because the output is valid.

Test absent, explicit null, invalid, and valid dates. The null-versus-missing guide explains their different API meanings. Express required and nullable rules in schema and test the configured validator.

9. Does the complete JSON round trip preserve type and meaning?

JSON.parse returns date text as a string, not a Date object. Test serialization, parsing, schema validation, and application conversion as one round trip. Assert the JSON type, exact text, numeric instant, and date-only value so a refactor cannot silently change representation while leaving the payload syntactically valid.

Round-trip coverage catches framework transformations, field renames, precision changes, and client assumptions that isolated Date tests miss. Use a frozen fixture rather than the current clock so failures remain deterministic.

import assert from "node:assert/strict";
const response = {
  serviceDate: "2026-10-21",
  completedAt: new Date("2026-10-21T09:30:00.123Z")
};
const text = JSON.stringify(response);
const parsed = JSON.parse(text);
assert.equal(typeof parsed.serviceDate, "string");
assert.equal(typeof parsed.completedAt, "string");
assert.equal(parsed.serviceDate, "2026-10-21");
assert.equal(parsed.completedAt, "2026-10-21T09:30:00.123Z");
assert.equal(Date.parse(parsed.completedAt), response.completedAt.getTime());

Run the contract fixture in a representative consumer when the API spans languages. Confirm whether its generated model uses a string, date-only type, instant, or offset date-time. A JavaScript pass cannot guarantee a mobile SDK or database driver preserves identical semantics.

Keep the fixture in the API contract test suite and version it with the schema. When a field changes from date-only to instant, or milliseconds to microseconds, treat that as a compatibility decision rather than a formatting cleanup. Existing clients may parse successfully yet display, sort, or compare the new representation incorrectly.

Inspect a non-sensitive fixture with the JSON Viewer to confirm final types and tree. The viewer does not validate RFC 3339, timezone intent, or business rules; keep those assertions in automated tests and the API schema.

Frequently Asked Questions

Does JSON have a built-in date type?
No. JSON provides strings, numbers, booleans, null, arrays, and objects, but no date type. An API must define whether a temporal field is a calendar date, local date-time, UTC instant, offset date-time, or numeric epoch. Clients need that declared contract to parse and display the value correctly.
Should a JSON API use ISO 8601 or RFC 3339 dates?
Use the exact profile your contract names. RFC 3339 defines an interoperable Internet timestamp profile derived from ISO 8601 and underlies JSON Schema's date-time format. A plain date can use full-date form, while an instant should include Z or an explicit numeric offset. Document accepted precision and normalization too.
Why does a JavaScript Date lose the original timezone offset?
A JavaScript Date represents an instant as milliseconds from the epoch, not the original timezone identifier or offset text. Date.prototype.toJSON uses UTC ISO output, so an input such as 12:00+05:30 can become 06:30Z. The instant is preserved, but the sender's original offset and timezone context are not.
Can JSON Schema validate date-time strings?
JSON Schema defines date, time, and date-time format annotations based on RFC 3339. Whether they are asserted depends on the validator and its configuration, so test known invalid examples against the exact implementation used in production. Format validation also cannot decide business rules such as booking windows or future-date limits.
Should an API use epoch seconds or epoch milliseconds?
Either can work when the unit, range, and precision are explicitly documented, but the bare number does not identify its unit. Seconds and milliseconds differ by a factor of one thousand. Name the field clearly, constrain its range, and test a fixed known instant so accidental unit conversion fails before release.

Inspect your date fixtures as JSON

Open a non-sensitive payload as a tree and confirm every temporal field has the intended JSON type and value.

Open the JSON Viewer