Concern | Recommended format | Why |
---|---|---|
Dates | ISO-8601 UTC string (2025-06-18T12:34:56.789Z ) |
Human-readable, sortable, handled natively by every major client. |
BigInt | String | Avoids loss of precision in JS JSON. |
Binary | Base-64 string (or separate download URL) | Plays nicely with text-only transports. |
Document this in an “API contract” (OpenAPI / Swagger, README, etc.). Controllers then become the single place that converts between transport shapes and rich domain objects. (infinitejs.com)
// utils/jsonDates.js
const ISO_DATE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
export function reviver(key, value) {
return typeof value === 'string' && ISO_DATE.test(value)
? new Date(value)
: value;
}
export function replacer(key, value) {
// Date → ISO (Date.toJSON does this anyway, but lets you customise)
if (value instanceof Date) return value.toISOString();
return value;
}
import express from 'express';
import { reviver, replacer } from './utils/jsonDates.js';
const app = express();
// ↙ Deserialise request bodies
app.use(express.json({ reviver }));
app.post('/tasks', (req, res) => {
const { due } = req.body; // ← a real Date
/* domain logic here */
res.json({ due }); // → auto-replaced to ISO string
});
// Global response helper if you need it
app.response.jsonSafe = function (body) {
this.set('Content-Type', 'application/json');
this.send(JSON.stringify(body, replacer));
};
Pros: zero dependencies, transparent, testable. Cons: manual upkeep for every exotic type.
import { z } from 'zod';
const TaskDto = z.object({
title: z.string(),
due: z.string()
.datetime() // RFC3339 check
.transform(s => new Date(s)), // string → Date
});
// Controller
const dto = TaskDto.parse(req.body); // throws 400 on bad input
service.create(dto); // `due` is a Date
// Flip direction for responses
res.json(TaskDto.parse(domainTask).toJSON());
import { Type, Transform, plainToInstance } from 'class-transformer';
class TaskDto {
@Type(() => Date)
@Transform(({ value }) => new Date(value), { toClassOnly: true })
due!: Date;
title!: string;
}
// inbound
const dto = plainToInstance(TaskDto, req.body); // Date field hydrated
Decorator approach keeps DTOs explicit and self-documenting. (github.com)
import superjson from 'superjson';
// outbound
res.type('application/json').send(superjson.stringify(data));
// inbound middleware
app.use((req, _res, next) => {
if (req.is('application/json')) {
req.body = superjson.parse(req.rawBody.toString());
}
next();
});
SuperJSON serialises Date
, BigInt
, Map
, Set
, RegExp
, etc., embedding metadata so the client re-hydrates automatically. It powers tRPC’s “data transformers” for exactly this reason. (github.com, trpc.io)
- DTO layer lives in controllers – shields domain/service code from transport quirks.
- Do not override
Date.prototype.toJSON()
globally unless every consumer expects the new shape (easy foot-gun). - Unit-test edges: feed raw JSON into controllers, assert domain receives real
Date
instances; reverse for responses. - End-to-end contract tests: snapshot actual HTTP payloads so changes to the format break CI early.
- Monitor for drift: a lint rule (or TypeScript ESLint rule) can flag accidental
new Date().toString()
leakage.
- Small project / few exotic types → custom reviver/replacer functions.
- Typed codebase wanting validation →
zod
orclass-transformer
DTOs. - Full-stack TypeScript (tRPC/Next.js, etc.) → drop in SuperJSON for transparent transmission.
Whichever path you choose, keep all translation logic at the controller boundary, so your business logic stays blissfully unaware of how the outside world hugs its JSON.
Below are five progressively-richer patterns that show how you can use Zod (≥ v3.20) to keep all date-string ↔ Date
conversions right at the controller boundary while your service layer only sees real Date
objects.
// POST /tasks (body)
// { "title": "Book flights", "due": "2025-07-01T00:00:00Z" }
import { z } from "zod";
export const CreateTaskDto = z.object({
title: z.string().min(1),
// turns an ISO string into Date — fails on nonsense
due: z.coerce.date(), // since zod 3.20+ :contentReference[oaicite:0]{index=0}
});
type CreateTask = z.infer<typeof CreateTaskDto>; // {title: string; due: Date}
app.post("/tasks", (req, res) => {
const dto = CreateTaskDto.parse(req.body); // dto.due is a real Date
const task = service.createTask(dto); // <-- business logic
res.json(task); // Dates stringify via toJSON()
});
// GET /events?from=2025-06-01&to=2025-06-30
const EventsQuery = z.object({
from: z.coerce.date().default(() => new Date("1970-01-01")),
to: z.coerce.date(),
}).refine(q => q.to >= q.from, { message: "`to` must be on/after `from`" });
app.get("/events", (req, res) => {
const q = EventsQuery.parse(req.query); // {from: Date, to: Date}
res.json(service.listEvents(q));
});
const BookingDto = z.object({
start: z.coerce.date(),
end: z.coerce.date(),
}).superRefine((val, ctx) => {
if (val.end <= val.start) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["end"],
message: "End date must be after start date",
});
}
});
superRefine
lets you attach errors to specific fields instead of throwing a generic error. (truecoderguru.com)
// ---- INBOUND (request) schema ----
export const UserIn = z.object({
name: z.string(),
birthday: z.coerce.date(),
});
type UserCreate = z.infer<typeof UserIn>; // birthday: Date
// ---- OUTBOUND (response) schema ----
export const UserOut = z.object({
id: z.string().uuid(),
name: z.string(),
birthday: z.date()
.transform(d => d.toISOString()) // Date -> ISO string
.describe("ISO-8601"),
});
type UserResponse = z.infer<typeof UserOut>; // birthday: string
// Controller
app.post("/users", (req, res) => {
const input = UserIn.parse(req.body); // Date inside
const user = service.addUser(input); // returns domain entity
res.json(UserOut.parse(user)); // ISO string out
});
Why two schemas? transform
only runs one way; trying to reuse a single schema for both directions usually leads to surprising types.
// PATCH /tasks/:id
const PatchTaskDto = CreateTaskDto.partial().strict(); // any subset of props
app.patch("/tasks/:id", (req, res) => {
const patch = PatchTaskDto.parse(req.body); // unknown keys rejected
const updated = service.updateTask(req.params.id, patch);
res.json(UserOut.parse(updated)); // reuse outbound model
});
Situation | Technique |
---|---|
Accepting null or undefined in JSON |
Chain .nullable() or .optional() before z.coerce.date() . |
For HTML <input type="date"> (no time) |
z.string().regex(/^\d{4}-\d{2}-\d{2}$/).transform(s => new Date(s + "T00:00:00Z")) . |
Need custom timezone handling | Prefer a real date lib (Day.js/Date-fns) inside transform or refine . |
Return BigInt / Decimal.js objects | Use z.bigint().transform(b => b.toString()) (same pattern). |
Want OpenAPI docs | Wrap Zod with zod-to-openapi or use NestJS’ @nest-plugins/zod . |
src/
├─ controllers/
│ └─ task.controller.ts // only Zod DTOs + HTTP
├─ services/
│ └─ task.service.ts // only real Dates
└─ schemas/
└─ task.dto.ts // CreateTaskDto, PatchTaskDto, TaskOut
All the (de)serialization friction stays inside controllers/
and schemas/
, leaving services/
blissfully unaware of JSON.
- Zod docs — Dates & coercion for deeper options on ISO validation. (zod.dev)
- tRPC →
transformer: superjson
if you prefer automatic multi-type support instead of hand-rolling transforms. (stackoverflow.com, answeroverflow.com)
Let me know if you’d like examples for query-builders, NestJS pipes, or integrating with front-end forms!
`res.json()` is just a convenience wrapper around **`JSON.stringify()`**.
When you pass it any JavaScript object, Express will ultimately do:
res.setHeader('Content-Type', 'application/json');
res.send(JSON.stringify(body)); // simplified
-
Every
Date
instance has a built-intoJSON()
method. That method simply callstoISOString()
and returns something like"2025-06-18T09:15:42.123Z"
. (developer.mozilla.org) -
During stringification,
JSON.stringify()
checks each value for atoJSON()
method.- If such a method exists, it is invoked and the returned value is what gets written into the JSON text.
-
Because
Date.prototype.toJSON()
exists, the moment yourtask
object reachesJSON.stringify()
, everyDate
property is replaced with its ISO string.
const task = {
title: 'Book flights',
due: new Date('2025-07-01T00:00:00Z')
};
console.log(JSON.stringify(task));
// => {"title":"Book flights","due":"2025-07-01T00:00:00.000Z"}
So the comment in the sample code—// Dates stringify via toJSON()
—is just a reminder that you don’t have to convert Date
objects yourself at response time; the standard toJSON()
mechanism does it for you when res.json()
serializes the payload.
Bonus tip: You can override or add a custom
toJSON()
to any class if you need a different wire format, but think twice—it affects every serialization call site. Consider using a Zod.transform()
or a dedicated DTO layer instead of patching the globalDate
behavior.
Further reading: Express’ res.json()
docs, noting that it “sends a JSON response … converted to a JSON string using JSON.stringify()
.” (geeksforgeeks.org)
Yes — if absolutely nothing inside your codebase ever needs date math (no comparisons, no .getTime()
, no formatting) you can keep the value as an ISO-8601 string and skip the new Date(...)
round-trip. Just make sure you still validate that the string is in the format you expect, or you’ll hand garbage to the next layer.
Criterion | Keep as string | Convert to Date |
---|---|---|
Cost | Zero allocations after parse; minor CPU savings in tight loops. | One small object allocation + a few µs parse; negligible for typical web traffic. |
Type safety (TS) | Plain string can mix with any other string unless you brand it. |
Native Date methods make misuse obvious at compile-time. |
Comparison / sorting | Works only if you enforce canonical ISO strings (2025-06-18T12:00:00Z ). |
Works out of the box (dateA < dateB ). |
Arithmetic (add days, diff) | You’ll end up parsing later anyway or rolling your own helpers. | Built-ins like .getTime() or date-fns operate directly. |
Time-zone semantics | Easy to leak “local” strings (18/06/2025 ) that sort incorrectly. |
Date holds an absolute UTC instant; formatting happens on demand. |
JSON I/O | Leaves the value untouched; still human-readable wire format. | Serialises back to the same ISO string automatically thanks to Date.prototype.toJSON() — invoked by JSON.stringify() . (developer.mozilla.org, developer.mozilla.org) |
Optimise for clarity over micro-performance.
Unless you profile and prove Date
construction is a bottleneck, it’s safer to hand your service layer real Date
objects so future changes (sorting, date math, reporting) don’t blow up.
type IsoDateString = string & { readonly __brand: unique symbol };
const IsoDate = z.string()
.regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/)
.transform(s => s as IsoDateString);
// Controller
const dto = z.object({ shippedAt: IsoDate }).parse(req.body);
// dto.shippedAt is a branded string, not a naked one
Benefits: prevents you from accidentally concatenating two dates or passing a non-ISO string; no runtime cost beyond validation.
// inbound
const orderIn = z.object({
id: z.string().uuid(),
shippedAt: IsoDate,
});
// outbound — maybe you pipe the domain entity straight back
res.json(orderIn.parse(order)); // still a string
function isLate(shipped: IsoDateString, now: Date = new Date()): boolean {
return Date.parse(shipped) < now.getTime() - 3 * 24 * 60 * 60 * 1000;
}
Parsing happens only in the tiny slice of code that truly needs it.
- Sorting large collections (
orders.sort((a,b)=>a.shippedAt-b.shippedAt)
is trivial if both areDate
or epoch numbers). - Any arithmetic (add 30 days, compute age).
- Interfacing with libraries (ORMs, charting, date-fns, Temporal Polyfill).
- Avoiding subtle bugs where someone joins two ISO strings and silently corrupts data.
The conversion cost is usually drowned by network latency or database time, so default to correctness.
Domain layer → always objects Transport/storage layer → strings or epoch numbers
Do the minimal parse/format work at the boundary once; enjoy strong types and safe math everywhere else. If you truly only ferry the value around unchanged, branded strings are fine—but make that an explicit design decision, not an incidental shortcut.
type IsoDateString = string & { readonly __brand: unique symbol };
declare const d1: IsoDateString;
declare const d2: IsoDateString;
Action | What the compiler sees | Safe? | Why it helps |
---|---|---|---|
Pass raw stringsaveUser({ birthday: "2025-06-18" }) |
"2025-06-18" has no brand → error |
✅ | Forces you to validate/brand first (IsoDate.parse(...) ). |
Concatenate two ISO datesconst bad = d1 + d2; |
Result type is plain string (brand is stripped) |
✅ | You can still concat, but… |
…then pass the concat back as a datesaveUser({ birthday: bad }) |
bad is plain string → error |
✅ | Prevents “2025…Z2026…Z” from masquerading as a date. |
Correct flowconst iso: IsoDateString = IsoDate.parse(req.body.birthday) |
IsoDate.parse returns a branded value |
✅ | Brand only appears after validation. |
IsoDateString
is still structurally astring
, but the extra intersection{ __brand: … }
keeps it from being assignable to plainstring
the other way round.- Any normal string operation (
+
,slice
, template-literal interpolation, etc.) yields a fresh un-brandedstring
. The brand isn’t copied because TypeScript has no rule saying “all results of+
keep the left-hand brand.”
So:
- You can’t feed an arbitrary string into code that expects an ISO date unless you re-validate or cast.
- You can’t silently funnel the result of a bad concatenation back into date-expecting code—the compiler stops you.
That’s all compile-time; there’s no runtime object or extra property—hence “no runtime cost beyond the one-time Zod validation at the edge.”
Rule of thumb Use branding for values that are logically “opaque” once validated. The type becomes a token that can only be obtained through the right constructor/validator, and any string manipulation that might corrupt it automatically drops the token.