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