| 1 | /** |
| 2 | * Formats JavaScript values into readable strings. |
| 3 | * |
| 4 | * `format` is intended for logs, diagnostics, and error messages. It handles |
| 5 | * primitives, objects, arrays, dates, regular expressions, maps, sets, class |
| 6 | * instances, errors, circular references, and redactable values. `formatJson` |
| 7 | * wraps JSON formatting with redaction and circular-reference handling, and the |
| 8 | * module also includes helpers for property keys, paths, and dates. |
| 9 | * |
| 10 | * @since 4.0.0 |
| 11 | */ |
| 12 | import * as Predicate from "./Predicate.ts" |
| 13 | import { getRedacted, redact, symbolRedactable } from "./Redactable.ts" |
| 14 | |
| 15 | /** |
| 16 | * A callable interface representing a function that converts a `Value` into a `Format`, which defaults to `string`. |
| 17 | * |
| 18 | * **When to use** |
| 19 | * |
| 20 | * Use when you want to type a formatting or rendering function generically, or when you are building a pipeline that accepts pluggable formatters. |
| 21 | * |
| 22 | * **Details** |
| 23 | * |
| 24 | * This is a pure callable type and carries no runtime implementation. It is contravariant in `Value` and covariant in `Format`. |
| 25 | * |
| 26 | * **Example** (Defining a custom formatter) |
| 27 | * |
| 28 | * ```ts import.meta.vitest |
| 29 | * import type { Formatter } from "effect" |
| 30 | * |
| 31 | * const upper: Formatter.Formatter<string> = (s) => s.toUpperCase() |
| 32 | * |
| 33 | * upper("hello") // => "HELLO" |
| 34 | * ``` |
| 35 | * |
| 36 | * @see {@link format} |
| 37 | * @see {@link formatJson} |
| 38 | * @category models |
| 39 | * @since 4.0.0 |
| 40 | */ |
| 41 | export interface Formatter<in Value, out Format = string> { |
| 42 | (value: Value): Format |
| 43 | } |
| 44 | |
| 45 | /** |
| 46 | * Converts any JavaScript value into a human-readable string. |
| 47 | * |
| 48 | * **When to use** |
| 49 | * |
| 50 | * Use when you need to format arbitrary JavaScript values for debugging, |
| 51 | * logging, or error messages. |
| 52 | * |
| 53 | * **Details** |
| 54 | * |
| 55 | * - Output is **not** valid JSON; use {@link formatJson} when you need |
| 56 | * parseable JSON. |
| 57 | * - Handles `BigInt`, `Symbol`, `Set`, `Map`, `Date`, `RegExp`, and class |
| 58 | * instances that `JSON.stringify` cannot represent. |
| 59 | * - Circular references are shown as `"[Circular]"` instead of throwing. |
| 60 | * - Failures while inspecting a value are rendered as diagnostic placeholders instead of throwing. |
| 61 | * - Primitives: stringified naturally (`null`, `undefined`, `123`, `true`). |
| 62 | * Strings are JSON-quoted. |
| 63 | * - Objects with a custom `toString` (not `Object.prototype.toString`): |
| 64 | * `toString()` is called unless `ignoreToString` is `true`. |
| 65 | * - Errors with a `cause`: formatted as `"<message> (cause: <cause>)"`. |
| 66 | * - Iterables (`Set`, `Map`, etc.): formatted as |
| 67 | * `ClassName([...elements])`. |
| 68 | * - Class instances: wrapped as `ClassName({...})`. |
| 69 | * - `Redactable` values are automatically redacted. |
| 70 | * - Arrays/objects with 0–1 entries are inline; larger ones are |
| 71 | * pretty-printed when `space` is set. |
| 72 | * - `space` — indentation unit (number of spaces, or a string like |
| 73 | * `"\t"`). Defaults to `0` (compact). |
| 74 | * - `ignoreToString` — skip calling `toString()`. Defaults to `false`. |
| 75 | * |
| 76 | * **Example** (Formatting compact output) |
| 77 | * |
| 78 | * ```ts import.meta.vitest |
| 79 | * import { Formatter } from "effect" |
| 80 | * |
| 81 | * Formatter.format({ a: 1, b: [2, 3] }) // => "{\"a\":1,\"b\":[2,3]}" |
| 82 | * ``` |
| 83 | * |
| 84 | * **Example** (Pretty-printed output) |
| 85 | * |
| 86 | * ```ts import.meta.vitest |
| 87 | * import { Formatter } from "effect" |
| 88 | * |
| 89 | * const output = Formatter.format({ a: 1, b: [2, 3] }, { space: 2 }) |
| 90 | * output // => "{\n \"a\": 1,\n \"b\": [\n 2,\n 3\n ]\n}" |
| 91 | * ``` |
| 92 | * |
| 93 | * **Example** (Handling circular references) |
| 94 | * |
| 95 | * ```ts import.meta.vitest |
| 96 | * import { Formatter } from "effect" |
| 97 | * |
| 98 | * const obj: any = { name: "loop" } |
| 99 | * obj.self = obj |
| 100 | * Formatter.format(obj) // => "{\"name\":\"loop\",\"self\":[Circular]}" |
| 101 | * ``` |
| 102 | * |
| 103 | * @see {@link formatJson} |
| 104 | * @see {@link Formatter} |
| 105 | * @category formatting |
| 106 | * @since 2.0.0 |
| 107 | */ |
| 108 | export function format(input: unknown, options?: { |
| 109 | readonly space?: number | string | undefined |
| 110 | readonly ignoreToString?: boolean | undefined |
| 111 | }): string { |
| 112 | const space = options?.space ?? 0 |
| 113 | const ancestors = new WeakSet<object>() |
| 114 | const gap = !space ? "" : (typeof space === "number" ? " ".repeat(space) : space) |
| 115 | const ind = (d: number) => gap.repeat(d) |
| 116 | |
| 117 | const wrap = (v: unknown, body: string): string => { |
| 118 | const ctor = (v as any)?.constructor |
| 119 | return ctor && ctor !== Object.prototype.constructor && ctor.name ? `${ctor.name}(${body})` : body |
| 120 | } |
| 121 | |
| 122 | const ownKeys = (o: object): Array<PropertyKey> => { |
| 123 | try { |
| 124 | return Reflect.ownKeys(o) |
| 125 | } catch { |
| 126 | return ["[ownKeys threw]"] |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | function recur(v: unknown, d = 0): string { |
| 131 | try { |
| 132 | return recurUnsafe(v, d) |
| 133 | } catch { |
| 134 | if ((typeof v === "object" && v !== null) || typeof v === "function") ancestors.delete(v) |
| 135 | return "[inspection threw]" |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | function recurUnsafe(v: unknown, d = 0): string { |
| 140 | if (typeof v === "string") return JSON.stringify(v) |
| 141 | |
| 142 | if ( |
| 143 | typeof v === "number" || |
| 144 | v == null || |
| 145 | typeof v === "boolean" || |
| 146 | typeof v === "symbol" |
| 147 | ) return String(v) |
| 148 | |
| 149 | if (typeof v === "bigint") return String(v) + "n" |
| 150 | |
| 151 | if (typeof v === "object" || typeof v === "function") { |
| 152 | if (ancestors.has(v)) return CIRCULAR |
| 153 | ancestors.add(v) |
| 154 | |
| 155 | let output: string |
| 156 | if (symbolRedactable in v) { |
| 157 | output = recur(getRedacted(v as any), d) |
| 158 | } else if (Array.isArray(v)) { |
| 159 | output = !gap || v.length <= 1 |
| 160 | ? `[${v.map((x) => recur(x, d)).join(",")}]` |
| 161 | : `[\n${ind(d + 1)}${v.map((x) => recur(x, d + 1)).join(",\n" + ind(d + 1))}\n${ind(d)}]` |
| 162 | } else if (v instanceof Date) { |
| 163 | output = formatDate(v) |
| 164 | } else if ( |
| 165 | !options?.ignoreToString && |
| 166 | Predicate.hasProperty(v, "toString") && |
| 167 | typeof v["toString"] === "function" && |
| 168 | v["toString"] !== Object.prototype.toString && |
| 169 | v["toString"] !== Array.prototype.toString |
| 170 | ) { |
| 171 | const s = safeToString(v) |
| 172 | output = v instanceof Error && v.cause !== undefined ? `${s} (cause: ${recur(v.cause, d)})` : s |
| 173 | } else if (Symbol.iterator in v) { |
| 174 | output = `${v.constructor.name}(${recur(Array.from(v as any), d)})` |
| 175 | } else { |
| 176 | const keys = ownKeys(v) |
| 177 | if (!gap || keys.length <= 1) { |
| 178 | const body = `{${keys.map((k) => `${formatPropertyKey(k)}:${recur(safeGet(v, k), d)}`).join(",")}}` |
| 179 | output = wrap(v, body) |
| 180 | } else { |
| 181 | const body = `{\n${ |
| 182 | keys.map((k) => `${ind(d + 1)}${formatPropertyKey(k)}: ${recur(safeGet(v, k), d + 1)}`).join(",\n") |
| 183 | }\n${ind(d)}}` |
| 184 | output = wrap(v, body) |
| 185 | } |
| 186 | } |
| 187 | ancestors.delete(v) |
| 188 | return output |
| 189 | } |
| 190 | |
| 191 | return String(v) |
| 192 | } |
| 193 | |
| 194 | return recur(input, 0) |
| 195 | } |
| 196 | |
| 197 | const CIRCULAR = "[Circular]" |
| 198 | |
| 199 | /** |
| 200 | * @internal |
| 201 | */ |
| 202 | export function formatPropertyKey(name: PropertyKey): string { |
| 203 | return typeof name === "string" ? JSON.stringify(name) : String(name) |
| 204 | } |
| 205 | |
| 206 | /** |
| 207 | * Formats an array of property keys as a bracket-notation path string. |
| 208 | * |
| 209 | * @internal |
| 210 | */ |
| 211 | export function formatPath(path: ReadonlyArray<PropertyKey>): string { |
| 212 | return path.map((key) => `[${formatPropertyKey(key)}]`).join("") |
| 213 | } |
| 214 | |
| 215 | /** |
| 216 | * Formats a `Date` as an ISO 8601 string, returning `"Invalid Date"` for |
| 217 | * invalid dates instead of throwing. |
| 218 | * |
| 219 | * @internal |
| 220 | */ |
| 221 | export function formatDate(date: Date): string { |
| 222 | try { |
| 223 | return date.toISOString() |
| 224 | } catch { |
| 225 | return "Invalid Date" |
| 226 | } |
| 227 | } |
| 228 | |
| 229 | function safeToString(input: any): string { |
| 230 | try { |
| 231 | const s = input.toString() |
| 232 | return typeof s === "string" ? s : String(s) |
| 233 | } catch { |
| 234 | return "[toString threw]" |
| 235 | } |
| 236 | } |
| 237 | |
| 238 | function safeGet(input: object, key: PropertyKey): unknown { |
| 239 | try { |
| 240 | return (input as any)[key] |
| 241 | } catch { |
| 242 | return "[property access threw]" |
| 243 | } |
| 244 | } |
| 245 | |
| 246 | /** |
| 247 | * Stringifies a value to JSON safely, silently dropping circular references. |
| 248 | * |
| 249 | * **When to use** |
| 250 | * |
| 251 | * Use when you need valid JSON output, unlike `format`, and the input may |
| 252 | * contain circular references that should be silently omitted rather than |
| 253 | * throwing a `TypeError`. |
| 254 | * |
| 255 | * **Details** |
| 256 | * |
| 257 | * Uses `JSON.stringify` internally with a replacer that tracks the current |
| 258 | * object ancestry. Circular references are replaced with `undefined`, which |
| 259 | * omits them from object output. `Redactable` values are automatically redacted |
| 260 | * before serialization. `BigInt` values are stringified with an `n` suffix. |
| 261 | * `Error` instances without a `toJSON` property include their enumerable |
| 262 | * properties plus `name` and `message`. Errors with `toJSON` keep their custom |
| 263 | * representation. Other values follow standard `JSON.stringify` behavior. The |
| 264 | * `space` parameter controls indentation and defaults to `0`. |
| 265 | * |
| 266 | * **Gotchas** |
| 267 | * |
| 268 | * When the root input is `undefined`, a symbol, or a function, `formatJson` |
| 269 | * returns `"null"` instead of the `undefined` returned by `JSON.stringify`. |
| 270 | * Nested values retain standard `JSON.stringify` behavior. |
| 271 | * |
| 272 | * **Example** (Formatting compact JSON) |
| 273 | * |
| 274 | * ```ts import.meta.vitest |
| 275 | * import { Formatter } from "effect" |
| 276 | * |
| 277 | * Formatter.formatJson({ name: "Alice", age: 30 }) // => "{\"name\":\"Alice\",\"age\":30}" |
| 278 | * ``` |
| 279 | * |
| 280 | * **Example** (Handling circular references) |
| 281 | * |
| 282 | * ```ts import.meta.vitest |
| 283 | * import { Formatter } from "effect" |
| 284 | * |
| 285 | * const obj: any = { name: "test" } |
| 286 | * obj.self = obj |
| 287 | * Formatter.formatJson(obj) // => "{\"name\":\"test\"}" |
| 288 | * ``` |
| 289 | * |
| 290 | * **Example** (Pretty-printed JSON) |
| 291 | * |
| 292 | * ```ts import.meta.vitest |
| 293 | * import { Formatter } from "effect" |
| 294 | * |
| 295 | * const output = Formatter.formatJson({ name: "Alice", age: 30 }, { space: 2 }) |
| 296 | * output // => "{\n \"name\": \"Alice\",\n \"age\": 30\n}" |
| 297 | * ``` |
| 298 | * |
| 299 | * @see {@link format} |
| 300 | * @see {@link Formatter} |
| 301 | * @category serialization |
| 302 | * @since 4.0.0 |
| 303 | */ |
| 304 | export function formatJson(input: unknown, options?: { |
| 305 | readonly space?: number | string | undefined |
| 306 | }): string { |
| 307 | const ancestors: Array<object> = [] |
| 308 | return JSON.stringify( |
| 309 | input, |
| 310 | function(this: object, key: string, value: unknown) { |
| 311 | const original = Object.getOwnPropertyDescriptor(this, key)?.value |
| 312 | const redacted = Predicate.hasProperty(original, symbolRedactable) |
| 313 | ? redact(original) |
| 314 | : redact(value) |
| 315 | if (typeof redacted === "bigint") { |
| 316 | return format(redacted) |
| 317 | } |
| 318 | if (typeof redacted !== "object" || redacted === null) { |
| 319 | return redacted |
| 320 | } |
| 321 | const current = redacted instanceof Error && !Predicate.hasProperty(redacted, "toJSON") |
| 322 | ? { ...redacted, name: redacted.name, message: redacted.message } |
| 323 | : redacted |
| 324 | while (ancestors.length > 0 && ancestors[ancestors.length - 1] !== this) { |
| 325 | ancestors.pop() |
| 326 | } |
| 327 | if (ancestors.includes(redacted)) { |
| 328 | return undefined // circular reference |
| 329 | } |
| 330 | ancestors.push(redacted) |
| 331 | if (current !== redacted) { |
| 332 | ancestors.push(current) |
| 333 | } |
| 334 | return current |
| 335 | }, |
| 336 | options?.space |
| 337 | ) ?? "null" |
| 338 | } |