Skip to content

Commit

Permalink
turns out Array#toString is weird
Browse files Browse the repository at this point in the history
  • Loading branch information
wjhsf committed Feb 21, 2024
1 parent d32dd2c commit 2f035ac
Show file tree
Hide file tree
Showing 2 changed files with 43 additions and 17 deletions.
27 changes: 14 additions & 13 deletions lib/__tests__/util.spec.ts
Original file line number Diff line number Diff line change
@@ -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) => {
Expand Down
33 changes: 29 additions & 4 deletions lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<object>,
): 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<object>,
): 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<T> {
promise: Promise<T>
Expand Down

0 comments on commit 2f035ac

Please sign in to comment.