diff --git a/lib/__tests__/util.spec.ts b/lib/__tests__/util.spec.ts index 6432d0f6..afcda6c2 100644 --- a/lib/__tests__/util.spec.ts +++ b/lib/__tests__/util.spec.ts @@ -1,25 +1,26 @@ import { safeToString } from '../utils' describe('safeToString', () => { - const basic = [ - undefined, - null, - true, - 'string', - 123, - 321n, - { object: 'yes' }, - [1, 'hello', true, null], - (a: number, b: number) => a + b, - Symbol('safeToString'), - ] + const recursiveArray: unknown[] = [1] + recursiveArray.push([[recursiveArray], 2, [[recursiveArray]]], 3) const testCases = [ - ...basic.map((input) => [input, String(input)]), + [undefined, 'undefined'], + [null, 'null'], + [true, 'true'], + ['string', 'string'], + [123, '123'], + [321n, '321'], + [{ object: 'yes' }, '[object Object]'], + [(a: number, b: number) => a + b, '(a, b) => a + b'], + [Symbol('safeToString'), 'Symbol(safeToString)'], [Object.create(null), '[object Object]'], + // eslint-disable-next-line no-sparse-arrays + [[1, 'hello', , undefined, , true, null], '1,hello,,,,true,'], [ [Object.create(null), Symbol('safeToString')], '[object Object],Symbol(safeToString)', ], + [recursiveArray, '1,,2,,3'], ] it.each(testCases)('works on %s', (input, output) => { diff --git a/lib/utils.ts b/lib/utils.ts index 8c1304b5..83628f5c 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -13,19 +13,44 @@ export interface ErrorCallback { export const objectToString = (obj: unknown) => Object.prototype.toString.call(obj) -/** Safely converts any value to string, using the value's own `toString` when available. */ -export const safeToString = (val: unknown): string => { +/** + * Converts an array to string, safely handling symbols, null prototype objects, and recursive arrays. + */ +const safeArrayToString = ( + arr: unknown[], + seenArrays: WeakSet, +): string => { + // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/toString#description + if (typeof arr.join !== 'function') return objectToString(arr) + seenArrays.add(arr) + const mapped = arr.map((val) => + val === null || val === undefined || seenArrays.has(val) + ? '' + : safeToStringImpl(val, seenArrays), + ) + return mapped.join() +} + +const safeToStringImpl = ( + val: unknown, + seenArrays?: WeakSet, +): string => { // Using .toString() fails for null/undefined and implicit conversion (val + "") fails for symbols // and objects with null prototype if (val === undefined || val === null || typeof val.toString === 'function') { - // Array#toString implicitly converts its values to strings, which is what we're trying to avoid - return Array.isArray(val) ? val.map(safeToString).join() : String(val) + return Array.isArray(val) + ? // Arrays have a weird custom toString that we need to replicate + safeArrayToString(val, seenArrays ?? new WeakSet()) + : String(val) } else { // This case should just be objects with null prototype, so we can just use Object#toString return objectToString(val) } } +/** Safely converts any value to string, using the value's own `toString` when available. */ +export const safeToString = (val: unknown) => safeToStringImpl(val) + /** Utility object for promise/callback interop. */ export interface PromiseCallback { promise: Promise