DraftThis article is currently in draft mode

Tight Javascriptify: Extensible Value Serialization

October 21, 2025

Tight Javascriptify: Extensible Value Serialization#

Serialize any JavaScript value to a readable string with hooks for custom transformations. Handles circular refs, Maps, Sets, Errors, and more.

Basic Usage#

import { tightJavaScriptify } from "@phosphor/utils";

tightJavaScriptify({ user: "alice", id: 42 });
// → {user:alice,id:42}

tightJavaScriptify(new Map([["key", "value"]]));
// → {$Map(1):[["key","value"]]}

tightJavaScriptify(new Error("failed"));
// → {name:Error,message:failed,stack:...}

Built-in Type Support#

function builtInPreHookForJsTypes(key: string, value: unknown): null | { value: unknown } {
  // Convert Map/Set to a standard object structure so that JSON.stringify can handle them
  if (value instanceof Map) {
    const entries = Array.from(value.entries());
    return { value: { [`$Map(${value.size})`]: entries } };
  }
  if (value instanceof Set) {
    const values = Array.from(value);
    return { value: { [`$Set(${value.size})`]: values } };
  }
  if (value instanceof Uint8Array) {
    return { value: { [`$Uint8Array(${value.length})`]: uint8ArrayToUtf8String(value) } };
  }
  if (value instanceof Date) {
    return { value: { $Date: value.toISOString() } };
  }
  return null;
}

Automatically handles:

  • Map/Set{$Map(n): entries} / {$Set(n): values}
  • Date{$Date: "2025-10-21T..."}
  • Uint8Array{$Uint8Array(n): "utf8 string"}
  • Error{name, message, stack}
  • Function"functionName" or truncated toString()
  • Circular refs[Circular? key: Object]

Extensible Hooks#

Add custom transformations with preHooks, hooks, or postHooks:

/**
 * Applies each hook in sequence. The first hook that returns a non-null object
 * wins. If none return anything, returns undefined.
 */
function applyHooks(key: string, value: unknown, hooks: TightJavaScriptifyHook[], seen = new WeakMap()): unknown {
  let current = value;
  if (isObject(current)) {
    if (seen.has(current)) return `[Circular? ${seen.get(current)}]`;
    seen.set(current, `${key}: ${toString(current)}`);
  }
  if (hooks.length > 0) {
    for (const hook of hooks) {
      const result = hook.replacer(key, current);
      if (result != null) {
        current = result.value;
      }
    }
  }
  current = toJSON(current);
  if (Array.isArray(current)) {
    for (let i = 0; i < current.length; i++) {
      current[i] = applyHooks(String(i), current[i], hooks, seen);
    }
  } else if (current && typeof current === "object") {
    const result: Record<string, unknown> = {};
    for (const [subKey, subValue] of Object.entries(current)) {
      result[subKey] = applyHooks(subKey, subValue, hooks, seen);
    }
    return result;
  }
  return current;
}

Example: Custom Class#

class UserId {
  constructor(public id: string) {}
}

tightJavaScriptify.hooks.push({
  name: "UserId",
  replacer: (key, value) => {
    if (value instanceof UserId) {
      return { value: { $UserId: value.id } };
    }
    return null;
  },
});

tightJavaScriptify({ user: new UserId("u123") });
// → {user:{$UserId:u123}}

Example: Redact Secrets#

tightJavaScriptify.preHooks.push({
  name: "RedactAPIKeys",
  replacer: (key, value) => {
    if (key === "apiKey" && typeof value === "string") {
      return { value: value.slice(0, 4) + "..." };
    }
    return null;
  },
});

tightJavaScriptify({ apiKey: "sk_live_abc123xyz" });
// → {apiKey:sk_l...}

Hook Execution Order#

// Single-pass traversal applies hooks in order:
[
  ...tightJavaScriptify.preHooks, // Built-in JS types (Map/Set/Date)
  ...extraHooks, // Per-call custom hooks
  ...tightJavaScriptify.hooks, // Global user hooks
  ...tightJavaScriptify.postHooks, // Error/Function fallbacks
];

Per-Call Hooks#

Pass one-off hooks without mutating global state:

const redactEmail = {
  name: "RedactEmail",
  replacer: (key: string, value: unknown) => {
    if (key === "email") return { value: "***" };
    return null;
  },
};

tightJavaScriptify({ email: "alice@example.com" }, [redactEmail]);
// → {email:***}

Full Source#

/**
 * Serializes a value to a "tight" JavaScript-ish string.
 * By default, it handles circular objects, Errors, etc. If you wish to add
 * custom transformations, push a new hook to `tightJavaScriptify.hooks`.
 *
 * Performance optimization: This implementation performs a single traversal of the data
 * structure while maintaining the order of hook application (preHooks -> extraHooks ->
 * hooks -> postHooks). This avoids multiple recursive traversals of the same structure.
 *
 * Example usage:
 * ```ts
 * tightJavaScriptify.hooks.push({
 *   name: "myHook",
 *   replacer: (key, value) => {
 *     if (isMySpecialObject(value)) {
 *       return { value: transformMyObject(value) };
 *     }
 *     return null;
 *   }
 * })
 * const result = tightJavaScriptify(myData);
 *
 * // For custom preHooks:
 * tightJavaScriptify.preHooks.push({
 *   name: "replaceDates",
 *   replacer: (key, value) => {
 *     if (value instanceof Date) {
 *       return { value: { $Date: value.toISOString() } };
 *     }
 *     return null;
 *   }
 * });
 *
 * // We also have a default builtInPreHookForMapAndSet that handles Map/Set
 * // You can remove it if you wish to override map or set transformations:
 * tightJavaScriptify.preHooks = tightJavaScriptify.preHooks.filter(h => h.name !== "builtInMapSetHook");
 * ```
 */
function tightJavaScriptify(value: unknown, extraHooks?: (Falsey | TightJavaScriptifyHook)[]): string {
  let traversed = toJSON(value);

  const combinedHooks = [
    ...tightJavaScriptify.preHooks,
    ...(extraHooks ?? []),
    ...tightJavaScriptify.hooks,
    ...tightJavaScriptify.postHooks,
  ].filter(isTruthy);
  // Single traversal applying all hooks in sequence
  traversed = applyHooks("", traversed, combinedHooks);
  // try {
  //   traversed.hooked = combinedHooks.map((h) => h.name).join(" -> ");
  // } catch {}

  if (traversed === undefined) return "undefined";
  if (traversed === null) return "null";

  const result = (tightJsonStringify(traversed) ?? "undefined")
    // Convert quoted property keys to unquoted if valid
    .replace(/(\\?")([^"]+)\1:/g, "$2:")
    // Turn our placeholder UNDEFINED back into "undefined"
    .replace(UNDEFINED_RE, "undefined");

  return result;
}

Performance#

Single-pass traversal with hook composition avoids multiple recursive walks. Hooks execute sequentially until one returns a transformed value.

Use Cases#

  • DevString serialization – Capture complex context in dev messages
  • Logging – Readable structured logs without circular ref errors
  • Debugging – Quick object inspection with custom formatters
  • Test snapshots – Consistent serialization across types