Handling DateTime Scalars Consistently Across Subgraphs

DateTime is the scalar every federated graph has and almost none of them define consistently. Composition matches it by name, so three subgraphs can each parse and emit a different thing while the supergraph advertises one type — and clients discover the difference by sending a value one service accepts and another rejects. This page covers the decisions that make a temporal scalar behave the same everywhere. It sits under Custom Scalars in Federated GraphQL Schemas.

When to use this pattern

  • Use it the moment a second subgraph declares a temporal scalar, which is usually before anyone notices the first one exists.
  • Use it when clients report that a timestamp “works in one place and not another” — the classic symptom of name-only composition.
  • Skip a custom temporal scalar entirely for an output-only field in a single subgraph, where String with a documented format costs less and promises less.

Prerequisites

Four temporal concepts, not one

The root cause of most DateTime trouble is that four genuinely different things get modelled as one scalar. Separating them removes more bugs than any amount of parsing care.

An instant is a moment on the universal timeline — when an order was placed. It has no timezone of its own; rendering it in a timezone is a presentation decision.

A local date is a calendar day with no time and no zone — a birthday, an invoice date. Storing it as an instant is the classic bug that makes a date shift by one day for users west of you.

A zoned time is a wall-clock time in a specific place — a shop opening at 09:00 in its own city. It is not an instant, because it recurs, and converting it to one loses the meaning.

A duration is a length rather than a point, and it has no business being a DateTime at all.

Modelling all four as one scalar forces every consumer to guess which they have, and the guess is wrong exactly when it matters.

Four concepts that are not interchangeable An instant, a local date, a zoned time and a duration each need their own representation. Modelling a local date as an instant shifts it by a day for some users; modelling a zoned time as an instant loses its recurrence. One scalar for four concepts is the actual bug concept scalar bug if you use DateTime instead instant — when it happened DateTime, UTC none — this is the right use local date — a calendar day Date shifts a day for some timezones zoned time — recurring local LocalTime + IANA zone breaks at daylight-saving changes duration — a length Int seconds, or Duration meaningless as a point in time Two of these bugs only appear for users in other timezones, which is why they reach production.

Implementation walkthrough

Ship one implementation from a shared package, exporting both the SDL fragment and the resolver so they cannot drift apart.

// @acme/graphql-scalars — the single implementation every subgraph imports
import { GraphQLScalarType, GraphQLError, Kind } from "graphql";

// Canonical form: RFC 3339, always normalised to UTC, seconds precision.
// Writing this rule down is more valuable than the code that enforces it.
const RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;

function parse(value: unknown): Date {
  if (typeof value !== "string" || !RFC3339.test(value)) {
    throw new GraphQLError(
      `DateTime expects RFC 3339, got ${JSON.stringify(String(value).slice(0, 40))}`,
      { extensions: { code: "BAD_USER_INPUT" } },
    );
  }
  const d = new Date(value);
  if (Number.isNaN(d.getTime())) {
    throw new GraphQLError("DateTime is syntactically valid but not a real instant", {
      extensions: { code: "BAD_USER_INPUT" },
    });
  }
  return d;
}

export const DateTimeScalar = new GraphQLScalarType({
  name: "DateTime",
  // Output: one canonical form, always UTC, so two subgraphs never disagree.
  serialize: (value) => (value instanceof Date ? value : new Date(value as string))
    .toISOString().replace(/\.\d{3}Z$/, "Z"),
  parseValue: parse,
  // Literals arrive as AST nodes and must be routed through the SAME validator,
  // or inline values bypass every rule variables are held to.
  parseLiteral: (node) => {
    if (node.kind !== Kind.STRING) {
      throw new GraphQLError("DateTime must be a string literal");
    }
    return parse(node.value);
  },
});

export const dateTimeTypeDefs = /* GraphQL */ `scalar DateTime`;
// every subgraph — imports both halves, defines neither
import { DateTimeScalar, dateTimeTypeDefs } from "@acme/graphql-scalars";

const schema = buildSubgraphSchema({
  typeDefs: [dateTimeTypeDefs, ownTypeDefs],
  resolvers: { DateTime: DateTimeScalar, ...ownResolvers },
});

Exporting only the SDL is the trap worth avoiding explicitly. A package that ships scalar DateTime and lets each service write its own coercion has distributed the problem rather than solved it, and it looks like a shared implementation in every code review.

Verification steps

Composition cannot detect divergence here, so the verification is a contract test that runs in every subgraph repository against the same fixtures.

// A shared fixture file, imported by every subgraph's test suite.
const ACCEPT = ["2026-03-04T09:15:00Z", "2026-03-04T09:15:00+01:00"];
const REJECT = ["2026-03-04", "04/03/2026", "1772614500", ""];

for (const v of ACCEPT) expect(() => DateTimeScalar.parseValue(v)).not.toThrow();
for (const v of REJECT) expect(() => DateTimeScalar.parseValue(v)).toThrow();

// Round-trip: what comes out must be accepted going back in, byte for byte.
const out = DateTimeScalar.serialize(new Date("2026-03-04T08:15:00.000Z"));
expect(out).toBe("2026-03-04T08:15:00Z");
expect(() => DateTimeScalar.parseValue(out)).not.toThrow();

The round-trip assertion is the one that catches real incidents. A serialiser emitting milliseconds and a parser rejecting them is a graph where a value read from one field cannot be written back to another — and it composes, passes every schema check, and fails only when a client tries it.

Then verify both input paths at the graph level, since a variable and an inline literal take different code paths:

query($at: DateTime!) { a: eventsAfter(at: $at) { id } }
query { b: eventsAfter(at: "not-a-date") { id } }   # must be rejected too
What catches each kind of divergence Composition catches none of these. Shared fixtures catch accepted-format differences, a round-trip test catches precision mismatches, and a literal test catches the parseLiteral gap. Composition catches none of these divergence what catches it one subgraph accepts bare dates a shared reject-list fixture in every repository millisecond precision differs a serialise-then-parse round-trip assertion parseLiteral skips validation an inline-literal query in the integration suite a local date stored as an instant nothing automated — modelling review is the only defence

When adopting a shared implementation across an existing graph, the order of adoption decides whether anything breaks. It is the same rule as any tightening change: loosest first.

Loosest adopts first A permissive service tightening early affects nobody; a strict service tightening early rejects live traffic. Adoption order for an existing graph subgraph adopts because the most permissive parser first it starts rejecting values nobody sends middle-of-the-road services second producers are already canonical the strictest parser last nothing changes for it at all producers of non-canonical values before the strict one or they start being rejected Reversing this order makes a strict service reject values a permissive one is still emitting. The same ordering applies to enums and to any other tightened shared contract.

Common mistakes & gotchas

Accepting whatever the platform’s date parser takes. A permissive parser makes one subgraph accept values another rejects, and the difference surfaces as a client bug in a service that never saw the value.

Emitting a form you would not accept. Milliseconds out, seconds-only in, and a value cannot round-trip through your own graph. Assert the round trip.

Using an instant for a calendar date. The value shifts by a day for users in the wrong direction from your servers, and it will be reported as a data-entry problem for months.

Frequently Asked Questions

Should the scalar be timezone-aware on output?

Emit UTC, always, and let clients render in whatever zone they need. A graph that emits local offsets forces every consumer to handle several forms and makes two subgraphs’ outputs incomparable. The exception is a genuinely zoned concept — a shop’s opening time — which is a different scalar, not a variation of this one.

How do we migrate an existing loose implementation?

Loosest subgraph first. The service that currently accepts the most formats adopts the shared strict implementation first, so it starts rejecting values nobody is sending yet; the strictest adopts last, when every producer already emits the canonical form. Reversing the order makes the strict service reject values a loose one is still producing.

Is String ever the right answer?

For an output-only field in one subgraph, with the format documented, yes — and it is honest, because a String promises nothing a client might wrongly rely on. The moment the value appears in an input position or in a second subgraph, the discipline of a real scalar is cheaper than the coordination of an informal convention.

Does a shared scalar package couple our release cycles?

Only when the scalar’s behaviour changes, which should be almost never. Pin the version, treat a behaviour change as a breaking change with its own migration, and the package sits still for years. That stability is what makes it a better mechanism than a copied SDL fragment, which drifts precisely because nobody notices it changing.

Should the canonical form be documented somewhere clients read?

Yes, and the scalar’s own description is the right place, because it is the one piece of documentation that travels with the schema into every client’s tooling. A description that states the accepted format, the emitted format, and the precision answers most of the questions this page exists to prevent, and it appears in editors and generated documentation without anyone maintaining a separate page.

Does this apply to money and identifiers too?

Exactly the same reasoning applies, and money is if anything worse, because a currency-free amount is ambiguous in a way a timestamp never is. Any scalar whose meaning depends on a convention rather than on its wire type belongs in the same shared package with the same round-trip test.

How do we handle values that predate the strict scalar?

Normalise on read rather than rejecting, for a bounded period. A serialiser that accepts a legacy stored form and emits the canonical one lets you tighten the API without a data migration, and it keeps the strictness at the boundary where it belongs. What you should not do is leave that tolerance in place permanently — it becomes a second accepted format that nobody documented, which is exactly the situation the shared scalar was meant to end.