Why Your JS Object Loses Its Methods Across Boundaries (and How to Fix It)

Last updated on

If you’ve ever created an instance like new DateTime() and it worked locally but later turned into a plain object with missing methods (e.g., .diff is not a function), you’ve hit prototype loss.

This post explains why it happens and shows copy-paste fixes you can apply today.


The Symptom

  • In the same module/file: dateTime.diff(...) works ✅
  • After passing it “somewhere” (API, storage, worker, SSR → CSR): it becomes a plain object; dateTime.diff is not a function

The Root Cause: Serialization Drops Prototypes

When objects are serialized (JSON.stringify) and deserialized (JSON.parse), JavaScript preserves data, not prototypes. Your class instances come back as plain objects without methods.

Minimal Reproduction

class MyDate {
  diff() {
    return "difference";
  }
}

const original = new MyDate();
console.log(original.diff()); // ✅ "difference"

const copy = JSON.parse(JSON.stringify(original));
console.log(copy.constructor); // Object
// @ts-expect-error
console.log(copy.diff());    // ❌ TypeError: copy.diff is not a function

Common Places This Happens

  • JSON.stringify / JSON.parse
  • HTTP JSON (server ↔ client)
  • postMessage (Web Workers)
  • localStorage / sessionStorage
  • SSR → CSR boundaries (Astro, Next.js, etc.)
  • Some structured-clone scenarios

Why Luxon’s DateTime Is Affected

Luxon’s methods (like .diff) live on the prototype. After serialization, the object becomes plain data (e.g., { ts, zone, ... }) with no methods.

import { DateTime } from "luxon";

const now = DateTime.now();
const json = JSON.stringify(now);
const plain = JSON.parse(json);

// @ts-expect-error
plain.diff(DateTime.now()); // ❌ TypeError

Fixes You Can Trust

Send a primitive (ISO string or epoch millis) and rebuild on the other side.

// Sender
import { DateTime } from "luxon";
const nowISO = DateTime.now().toISO();
// send `nowISO` as a plain string (JSON-safe)

// Receiver
import { DateTime } from "luxon";
const revived = DateTime.fromISO(nowISO);
revived.diff(DateTime.now()); // ✅ works

You can use toMillis / fromMillis if you prefer numbers:

const ms = DateTime.now().toMillis();
const revived = DateTime.fromMillis(ms);

2) Use a JSON Reviver (Auto-Rehydrate)

Tag your payload so you can restore types during JSON.parse.

// Serialize
import { DateTime } from "luxon";

const payload = {
  createdAt: { __type: "DateTime", value: DateTime.now().toISO() },
};

const json = JSON.stringify(payload);

// Deserialize
const revived = JSON.parse(json, (key, value) => {
  if (value && value.__type === "DateTime" && typeof value.value === "string") {
    return DateTime.fromISO(value.value);
  }
  return value;
});

If you can’t tag, you can heuristically detect ISO strings (be careful with false positives):

import { DateTime } from "luxon";

const revived = JSON.parse(json, (key, value) => {
  if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
    return DateTime.fromISO(value);
  }
  return value;
});

3) Compute Before Crossing the Boundary

If the consumer only needs the result, compute it first and send primitives.

// server
const diffDays = start.diff(end, "days").days;
return { diffDays }; // plain JSON

Real-World Patterns

Server → Client (API Example)

// server (any framework)
import { DateTime } from "luxon";

export async function handler() {
  const nowISO = DateTime.now().toISO();
  return new Response(JSON.stringify({ nowISO }), {
    headers: { "Content-Type": "application/json" },
  });
}

// client
import { DateTime } from "luxon";

const res = await fetch("/api/now");
const { nowISO } = await res.json();
const now = DateTime.fromISO(nowISO);
console.log(now.diff(DateTime.now()).milliseconds);

Web Worker

// main thread
worker.postMessage({ startedAt: Date.now() }); // primitives only

// worker
import { DateTime } from "luxon";

self.onmessage = (e) => {
  const startedAt = e.data.startedAt as number;
  const start = DateTime.fromMillis(startedAt);
  // safe to call methods here
};

Anti-Patterns to Avoid

  • Passing class instances through JSON and expecting methods to survive ❌
  • Assuming frameworks preserve prototypes automatically ❌
  • Mixing instances and plain objects without a reconstruction step ❌

TL;DR

  • Why it breaks: Serialization strips prototypes → methods disappear.
  • Fix it by: Sending primitives (ISO/millis) and rebuilding instances (DateTime.fromISO/fromMillis), or using a reviver, or computing results before sending.
  • Mindset: Treat cross-boundary communication as data-only; rehydrate types explicitly where you need their methods.