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
Stringwith 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.
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
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.
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.
Related
- Custom Scalars in Federated GraphQL Schemas — parent guide
- Sharing Custom Scalars Across Multiple Subgraphs — the packaging pattern used here
- Validating Custom Scalar Inputs Across Subgraphs — the two input paths in detail
- Managing Shared Enums Across Subgraphs — the same drift problem for enums
- Resolving Field Type Mismatch Composition Errors — what composition does catch