/** * Formats JavaScript values into readable strings. * * `format` is intended for logs, diagnostics, and error messages. It handles * primitives, objects, arrays, dates, regular expressions, maps, sets, class * instances, errors, circular references, and redactable values. `formatJson` * wraps JSON formatting with redaction and circular-reference handling, and the * module also includes helpers for property keys, paths, and dates. * * @stability stable * @since 4.0.0 */ import * as Predicate from "./Predicate.ts" import { getRedacted, redact, symbolRedactable } from "./Redactable.ts" /** * A callable interface representing a function that converts a `Value` into a `Format`, which defaults to `string`. * * **When to use** * * Use when you want to type a formatting or rendering function generically, or when you are building a pipeline that accepts pluggable formatters. * * **Details** * * This is a pure callable type and carries no runtime implementation. It is contravariant in `Value` and covariant in `Format`. * * **Example** (Defining a custom formatter) * * ```ts import.meta.vitest * import type { Formatter } from "effect" * * const upper: Formatter.Formatter = (s) => s.toUpperCase() * * upper("hello") // => "HELLO" * ``` * * @see {@link format} * @see {@link formatJson} * @stability stable * @category models * @since 4.0.0 */ export interface Formatter { (value: Value): Format } /** * Converts any JavaScript value into a human-readable string. * * **When to use** * * Use when you need to format arbitrary JavaScript values for debugging, * logging, or error messages. * * **Details** * * - Output is **not** valid JSON; use {@link formatJson} when you need * parseable JSON. * - Handles `BigInt`, `Symbol`, `Set`, `Map`, `Date`, `RegExp`, and class * instances that `JSON.stringify` cannot represent. * - Circular references are shown as `"[Circular]"` instead of throwing. * - Failures while inspecting a value are rendered as diagnostic placeholders instead of throwing. * - Primitives: stringified naturally (`null`, `undefined`, `123`, `true`). * Strings are JSON-quoted. * - Objects with a custom `toString` (not `Object.prototype.toString`): * `toString()` is called unless `ignoreToString` is `true`. * - Errors with a `cause`: formatted as `" (cause: )"`. * - Iterables (`Set`, `Map`, etc.): formatted as * `ClassName([...elements])`. * - Class instances: wrapped as `ClassName({...})`. * - `Redactable` values are automatically redacted. * - Arrays/objects with 0–1 entries are inline; larger ones are * pretty-printed when `space` is set. * - `space` — indentation unit (number of spaces, or a string like * `"\t"`). Defaults to `0` (compact). * - `ignoreToString` — skip calling `toString()`. Defaults to `false`. * * **Example** (Formatting compact output) * * ```ts import.meta.vitest * import { Formatter } from "effect" * * Formatter.format({ a: 1, b: [2, 3] }) // => "{\"a\":1,\"b\":[2,3]}" * ``` * * **Example** (Pretty-printed output) * * ```ts import.meta.vitest * import { Formatter } from "effect" * * const output = Formatter.format({ a: 1, b: [2, 3] }, { space: 2 }) * output // => "{\n \"a\": 1,\n \"b\": [\n 2,\n 3\n ]\n}" * ``` * * **Example** (Handling circular references) * * ```ts import.meta.vitest * import { Formatter } from "effect" * * const obj: any = { name: "loop" } * obj.self = obj * Formatter.format(obj) // => "{\"name\":\"loop\",\"self\":[Circular]}" * ``` * * @see {@link formatJson} * @see {@link Formatter} * @stability stable * @category formatting * @since 2.0.0 */ export function format(input: unknown, options?: { readonly space?: number | string | undefined readonly ignoreToString?: boolean | undefined }): string { const space = options?.space ?? 0 const ancestors = new WeakSet() const gap = !space ? "" : (typeof space === "number" ? " ".repeat(space) : space) const ind = (d: number) => gap.repeat(d) const wrap = (v: unknown, body: string): string => { const ctor = (v as any)?.constructor return ctor && ctor !== Object.prototype.constructor && ctor.name ? `${ctor.name}(${body})` : body } const ownKeys = (o: object): Array => { try { return Reflect.ownKeys(o) } catch { return ["[ownKeys threw]"] } } function recur(v: unknown, d = 0): string { try { return recurUnsafe(v, d) } catch { if ((typeof v === "object" && v !== null) || typeof v === "function") ancestors.delete(v) return "[inspection threw]" } } function recurUnsafe(v: unknown, d = 0): string { if (typeof v === "string") return JSON.stringify(v) if ( typeof v === "number" || v == null || typeof v === "boolean" || typeof v === "symbol" ) return String(v) if (typeof v === "bigint") return String(v) + "n" if (typeof v === "object" || typeof v === "function") { if (ancestors.has(v)) return CIRCULAR ancestors.add(v) let output: string if (symbolRedactable in v) { output = recur(getRedacted(v as any), d) } else if (Array.isArray(v)) { output = !gap || v.length <= 1 ? `[${v.map((x) => recur(x, d)).join(",")}]` : `[\n${ind(d + 1)}${v.map((x) => recur(x, d + 1)).join(",\n" + ind(d + 1))}\n${ind(d)}]` } else if (v instanceof Date) { output = formatDate(v) } else if ( !options?.ignoreToString && Predicate.hasProperty(v, "toString") && typeof v["toString"] === "function" && v["toString"] !== Object.prototype.toString && v["toString"] !== Array.prototype.toString ) { const s = safeToString(v) output = v instanceof Error && v.cause !== undefined ? `${s} (cause: ${recur(v.cause, d)})` : s } else if (Symbol.iterator in v) { output = `${v.constructor.name}(${recur(Array.from(v as any), d)})` } else { const keys = ownKeys(v) if (!gap || keys.length <= 1) { const body = `{${keys.map((k) => `${formatPropertyKey(k)}:${recur(safeGet(v, k), d)}`).join(",")}}` output = wrap(v, body) } else { const body = `{\n${ keys.map((k) => `${ind(d + 1)}${formatPropertyKey(k)}: ${recur(safeGet(v, k), d + 1)}`).join(",\n") }\n${ind(d)}}` output = wrap(v, body) } } ancestors.delete(v) return output } return String(v) } return recur(input, 0) } const CIRCULAR = "[Circular]" /** * @internal */ export function formatPropertyKey(name: PropertyKey): string { return typeof name === "string" ? JSON.stringify(name) : String(name) } /** * Formats an array of property keys as a bracket-notation path string. * * @internal */ export function formatPath(path: ReadonlyArray): string { return path.map((key) => `[${formatPropertyKey(key)}]`).join("") } /** * Formats a `Date` as an ISO 8601 string, returning `"Invalid Date"` for * invalid dates instead of throwing. * * @internal */ export function formatDate(date: Date): string { try { return date.toISOString() } catch { return "Invalid Date" } } function safeToString(input: any): string { try { const s = input.toString() return typeof s === "string" ? s : String(s) } catch { return "[toString threw]" } } function safeGet(input: object, key: PropertyKey): unknown { try { return (input as any)[key] } catch { return "[property access threw]" } } /** * Stringifies a value to JSON safely, silently dropping circular references. * * **When to use** * * Use when you need valid JSON output, unlike `format`, and the input may * contain circular references that should be silently omitted rather than * throwing a `TypeError`. * * **Details** * * Uses `JSON.stringify` internally with a replacer that tracks the current * object ancestry. Circular references are replaced with `undefined`, which * omits them from object output. `Redactable` values are automatically redacted * before serialization. `BigInt` values are stringified with an `n` suffix. * `Error` instances without a `toJSON` property include their enumerable * properties plus `name` and `message`. Errors with `toJSON` keep their custom * representation. Other values follow standard `JSON.stringify` behavior. The * `space` parameter controls indentation and defaults to `0`. * * **Gotchas** * * When the root input is `undefined`, a symbol, or a function, `formatJson` * returns `"null"` instead of the `undefined` returned by `JSON.stringify`. * Nested values retain standard `JSON.stringify` behavior. * * **Example** (Formatting compact JSON) * * ```ts import.meta.vitest * import { Formatter } from "effect" * * Formatter.formatJson({ name: "Alice", age: 30 }) // => "{\"name\":\"Alice\",\"age\":30}" * ``` * * **Example** (Handling circular references) * * ```ts import.meta.vitest * import { Formatter } from "effect" * * const obj: any = { name: "test" } * obj.self = obj * Formatter.formatJson(obj) // => "{\"name\":\"test\"}" * ``` * * **Example** (Pretty-printed JSON) * * ```ts import.meta.vitest * import { Formatter } from "effect" * * const output = Formatter.formatJson({ name: "Alice", age: 30 }, { space: 2 }) * output // => "{\n \"name\": \"Alice\",\n \"age\": 30\n}" * ``` * * @see {@link format} * @see {@link Formatter} * @stability stable * @category serialization * @since 4.0.0 */ export function formatJson(input: unknown, options?: { readonly space?: number | string | undefined }): string { const ancestors: Array = [] return JSON.stringify( input, function(this: object, key: string, value: unknown) { const original = Object.getOwnPropertyDescriptor(this, key)?.value const redacted = Predicate.hasProperty(original, symbolRedactable) ? redact(original) : redact(value) if (typeof redacted === "bigint") { return format(redacted) } if (typeof redacted !== "object" || redacted === null) { return redacted } const current = redacted instanceof Error && !Predicate.hasProperty(redacted, "toJSON") ? { ...redacted, name: redacted.name, message: redacted.message } : redacted while (ancestors.length > 0 && ancestors[ancestors.length - 1] !== this) { ancestors.pop() } if (ancestors.includes(redacted)) { return undefined // circular reference } ancestors.push(redacted) if (current !== redacted) { ancestors.push(current) } // Leave boxed primitives intact so JSON.stringify can unbox them natively. if (!hasGetter(current) || isJsonPrimitiveWrapper(current)) { return current } // JSON.stringify reads a getter once, then calls toJSON on the result // before the replacer sees it. Intercept that read to redact the value // first, instead of re-reading the getter in the replacer, which could // repeat side effects or return a different value. const serialized = new Proxy(current, { get(target, key) { const value = Reflect.get(target, key, target) return Object.getOwnPropertyDescriptor(target, key)?.get !== undefined ? redact(value) : value } }) ancestors.push(serialized) return serialized }, options?.space ) ?? "null" } function hasGetter(object: object): boolean { for (const key of Object.getOwnPropertyNames(object)) { if (Object.getOwnPropertyDescriptor(object, key)?.get !== undefined) { return true } } return false } function isJsonPrimitiveWrapper(object: object): boolean { // Built-in valueOf checks internal slots across realms without calling user code. for (const valueOf of [Number.prototype.valueOf, Boolean.prototype.valueOf, String.prototype.valueOf]) { try { Reflect.apply(valueOf, object, []) return true } catch { // Try the next wrapper type. } } return false }