Skip to content

Instantly share code, notes, and snippets.

@bsitruk
Last active June 19, 2025 10:00
Show Gist options
  • Save bsitruk/619f2198e028226e77e9a10bfc9eae09 to your computer and use it in GitHub Desktop.
Save bsitruk/619f2198e028226e77e9a10bfc9eae09 to your computer and use it in GitHub Desktop.

Serialization and Parsing Dates in JS

1. Agree on the “over-the-wire” shape first

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)


2. Lightweight DIY: a JSON reviver / replacer pair

// 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;
}

Plug into Express

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.


3. Schema-first with validation & transforms

a. zod (or Joi, superstruct, etc.)

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());

b. Decorator-based class-transformer (+class-validator, NestJS style)

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)


4. Drop-in “smart JSON” libraries

SuperJSON

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)


5. Architectural checklist

  1. DTO layer lives in controllers – shields domain/service code from transport quirks.
  2. Do not override Date.prototype.toJSON() globally unless every consumer expects the new shape (easy foot-gun).
  3. Unit-test edges: feed raw JSON into controllers, assert domain receives real Date instances; reverse for responses.
  4. End-to-end contract tests: snapshot actual HTTP payloads so changes to the format break CI early.
  5. Monitor for drift: a lint rule (or TypeScript ESLint rule) can flag accidental new Date().toString() leakage.

TL;DR

  • Small project / few exotic types → custom reviver/replacer functions.
  • Typed codebase wanting validationzod or class-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.


More Zod examples

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.


1. Simple create-DTO: coercion on a single field

// 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()
});

2. Query-string parsing: coercion + default + validation

// 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));
});

3. Cross-field business rules: superRefine for “end after start”

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)


4. Symmetrical inbound / outbound schemas:

keep domain clean, shape responses explicitly

// ---- 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.


5. Patch / partial updates: strict + partial + pipe

// 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
});

Tips & gotchas

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.

Folder layout sketch

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.


Where to go next?

  • 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!


JSON.stringify and .toJSON

`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

Why a Date turns into an ISO-8601 string automatically

  1. Every Date instance has a built-in toJSON() method. That method simply calls toISOString() and returns something like "2025-06-18T09:15:42.123Z". (developer.mozilla.org)

  2. During stringification, JSON.stringify() checks each value for a toJSON() method.

    • If such a method exists, it is invoked and the returned value is what gets written into the JSON text.
  3. Because Date.prototype.toJSON() exists, the moment your task object reaches JSON.stringify(), every Date 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 global Date behavior.


Further reading: Express’ res.json() docs, noting that it “sends a JSON response … converted to a JSON string using JSON.stringify().” (geeksforgeeks.org)


To parse Dates or not to parse ?

Short answer

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.


Strings vs Date objects — what you gain or lose

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)

Bottom line

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.


Practical patterns when you decide to keep strings

1. Brand the string in TypeScript

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.

2. Validate once at the edge, never inside

// 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

3. Convert just-in-time for rare calculations

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.


When you should bite the bullet and use Date

  1. Sorting large collections (orders.sort((a,b)=>a.shippedAt-b.shippedAt) is trivial if both are Date or epoch numbers).
  2. Any arithmetic (add 30 days, compute age).
  3. Interfacing with libraries (ORMs, charting, date-fns, Temporal Polyfill).
  4. 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.


A rule of thumb

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.


Branded Types

How the brand stops accidental misuse

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 string
saveUser({ birthday: "2025-06-18" })
"2025-06-18" has no branderror Forces you to validate/brand first (IsoDate.parse(...)).
Concatenate two ISO dates
const bad = d1 + d2;
Result type is plain string (brand is stripped) You can still concat, but…
…then pass the concat back as a date
saveUser({ birthday: bad })
bad is plain stringerror Prevents “2025…Z2026…Z” from masquerading as a date.
Correct flow
const iso: IsoDateString = IsoDate.parse(req.body.birthday)
IsoDate.parse returns a branded value Brand only appears after validation.

Why the brand disappears on d1 + d2

  • IsoDateString is still structurally a string, but the extra intersection { __brand: … } keeps it from being assignable to plain string the other way round.
  • Any normal string operation (+, slice, template-literal interpolation, etc.) yields a fresh un-branded string. 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment