--- title: "Tight Javascriptify: Extensible Value Serialization" date: "2025-10-21" draft: true --- # 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 ```typescript 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 ```typescript 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**: ```typescript /** * 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 = {}; for (const [subKey, subValue] of Object.entries(current)) { result[subKey] = applyHooks(subKey, subValue, hooks, seen); } return result; } return current; } ``` ### Example: Custom Class ```typescript 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 ```typescript 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 ```typescript // 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: ```typescript 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 ````typescript /** * 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