console: `%s` prints a Symbol and an object that `String()` cannot convert

RO
12d ago
+359-13

Problem

  • console.log("%s", value) throws TypeError: Cannot convert a symbol to a string for a Symbol and TypeError: No default value for an object with no toString (RegExp groups, util.parseArgs().values, Object.groupBy()).
  • The %s arm (src/jsc/ConsoleObject.rs:2382) sends every value to the engine ToString.

Fix

  • A Symbol prints Symbol(id). A plain object or module namespace that String() cannot convert prints as Bun.inspect(value, { compact: true, depth: 0 }) returns it.
  • Every other value keeps String(value). Of 951 probed values, 0 that print on 1.4.3 change.
  • Bun__hasNoStringConversion (UtilInspect.cpp) decides, and runs no getter or Proxy trap.
  • Verified: test/js/web/console/console-log.test.ts (10 new tests fail on 1.4.3), and the inspect suites. Self-reviewed: 16 concerns raised, 14 addressed (Notes).

Background

  • String(object) calls Symbol.toPrimitive, toString, then valueOf. A null-prototype object has none and throws.
  • The one-line object printer wrote two spaces before [Object ...] and kept its indent. Both are fixed.
  • Considered Node's rule, which also inspects { a: 1 }: it changes text that prints today. A maintainer must decide that (Notes).

Downsides

  • Per %s: a string +1 instruction (of 487). An object with a toString +36 to +84 (of 621 to 4,107), +265 for a new shape. Binary +5,632 bytes.
  • Still throws: an array, Map or Date with a null prototype, an array that holds a Symbol, %d/%i/%f with a BigInt.
Notes

Origin and scope. The report comes from an automated fuzz run. No user reported it, and no issue exists. Its repro has four %s lines: a Symbol, the groups of a RegExp match, util.parseArgs().values and Object.groupBy(). All four print with this PR. An earlier version of this PR also took Node's text for objects that print today. A review of it found regressions and no maintainer approval for the text change, so this PR is cut to the values that throw. It needs no answer to the question below. A trial merge with draft #44833 and with #43193 is clean.

The rule. %s goes to the old string printer unless one of these holds:

  • The value is a Symbol. The printer used the engine ToString, which throws for a Symbol. String(symbol) does not. The Symbol prints as Symbol(description).
  • The value is a revoked Proxy. It prints <Revoked Proxy>.
  • The value is a plain object (a class instance too) or a module namespace, and String(value) has nothing to call. That is the case when the prototype chain has no callable Symbol.toPrimitive and no callable toString, and valueOf is missing, not callable, or the built-in Object.prototype.valueOf (which returns the object). The value prints as Bun.inspect(value, { compact: true, depth: 0 }) returns it: the one-line form, nested objects as markers, no color, and a custom inspect function of the value is called.

An accessor, or a Proxy on the prototype chain, makes the answer "not known". String(value) then runs that code, as before. The rule first reads the toString that JavaScriptCore caches on the Structure after a conversion, so a shape that was converted before costs one load and one compare.

What prints now. 1.4.3 throws for each row.

ValueThis PRNode 26
Symbol("id")Symbol(id)Symbol(id)
/(?<year>[0-9]{4})-(?<month>[0-9]{2})/.exec("2026-10").groups[Object: null prototype] { year: "2026", month: "10" }[Object: null prototype] { year: '2026', month: '10' }
util.parseArgs({ args: ["--port", "80"], options: { port: { type: "string" } } }).values[Object: null prototype] { port: "80" }[Object: null prototype] { port: '80' }
Object.groupBy([1, 2, 3], n => (n % 2 ? "odd" : "even"))[Object: null prototype] { odd: [Array ...], even: [Array ...] }[Object: null prototype] { odd: [Array], even: [Array] }
req.params of a Bun.serve routeRequestParams { id: "7" }
JSON.parse('{"toString":1}'){ toString: 1 }{ toString: 1 }
import * as nsModule { a: 1 }[Module: null prototype] { a: 1 }
a revoked Proxy<Revoked Proxy><Revoked Proxy>

What does not change. A test prints 51 values through %s and compares each line with String(value) from the same process. It also compares the list of user functions that ran. The values include { a: 1 }, [1, 2], a Date, a Map, -0, 42n, an Error, a URL, a class with toString, objects with only valueOf or only Symbol.toPrimitive, an accessor toString, a Proxy with a get trap, an object with a custom inspect function, arguments, an iterator, a Response. A run of 951 values (every global, its prototype, an instance, and 60 shapes in 8 wrappings) on 1.4.3 and on this PR gives: 0 of the values that print on 1.4.3 change, 106 that threw now print, 177 still throw.

Two printer statements change outside the %s arm. Both are in the one-line form of the object printer, which Bun.inspect with compact, console.table and Bun.inspect.table use.

  • print_object_depth_exceeded wrote a second space before [Object ...]. Bun.inspect({ a: { b: 1 } }, { compact: true, depth: 0 }) printed { a: [Object ...] } and now prints { a: [Object ...] }. Draft #44833 and #43193 have the same statement.
  • print_object_tail gave back the indent level of the object only in the multi-line form. A multi-line value after a one-line object was one level too deep for each object before it. Bun.inspect({ a: { x: 1 }, b: { y: 2 }, r: new Response("") }, { compact: true }) indented the lines of the Response by 8 spaces and now indents them by 4, as without a and b. Under %s the same defect moved every argument after the inspected object.

Numbers. Release builds of 5f5799ed16 and of 5f5799ed16 with this diff. The instruction counts are gdb single-step counts of one warm call of Formatter::write_with_formatting for console.log("%s", value).

%s valuemainthis PR
string487488
integer, double, boolean, null, undefined472 to 508the same
BigInt934970
URL1,0251,061
Buffer1,4031,441
class instance with toString1,0921,176
function1,0911,129
Error2,2652,301
{ a: 1 }636720
[1, 2]752790
Date4,1074,143
Map621657
RegExp2,6522,690
{ a: 1 } with a new shape on each call5,5515,816
Symbol3,947 (throws)901
null-prototype { a: 1 }4,142 (throws)4,898
  • Other arms: no specifier 197 to 197, %o 839 to 839, %d 403 to 405, %f 1,401 to 1,405.
  • Whole call (consoleProtoFuncLog): console.log("hello") 1,318 to 1,318, console.log("hello world", i) 2,408 to 2,408, console.log("%s", "a string") 1,883 to 1,884.
  • Mallocs per call (breakpoint counts, 2,000 calls minus 1,000): 2 to 2 for a string, a URL, a class instance and { a: 1 }. A null-prototype object: 6 (throws) to 5. A Symbol: 6 (throws) to 2.
  • Binary (llvm-size): text 106,006,743 to 106,012,375 bytes (+5,632). The file grows by 4,096 bytes. Bun__hasNoStringConversion is 356 bytes and its lookup 4,266. print_string_substitution is 403 and 423 bytes (two color instances). write_with_formatting grows from 3,156 to 3,259 bytes (two instances).
  • The new code loads no JS module: a test counts the function executables before and after.

Still throws, and why it is not here.

  • An object of another kind that String() cannot convert: an array, arguments, a Map, a Set, a Date, an Error, a RegExp, a function, a typed array or a wrapper object with a null prototype. Also an array that holds an object with no conversion, and a Proxy of one. That is 81 of the 177. The printers of these kinds read length or size through the object, so a value that threw could hang or run a getter. They keep the TypeError.
  • An object whose own conversion throws. That is the other 96 of the 177: Date.prototype, Object.create(URL.prototype), Object(Symbol()), an object whose toString returns an object, an object whose Symbol.toPrimitive is not callable. String(value) throws the same error.
  • console.log("%s", [Symbol("a")]). String(array) exists and throws inside Array.prototype.join. Node prints it because Node inspects every array under %s. That belongs to the question below.
  • %d / %i / %f with a BigInt (TypeError: Conversion from 'BigInt' to 'number' is not allowed.). The asker left that half out, and it is a separate task. #31777 (%i of an array prints NaN) is also a different defect.
  • A format specifier in a later argument is still substituted: console.log("GET", "/users/%d0%b0", req.params) throws. #45571 has a fix for that.
  • console.assert takes the same arm. The test has no row for it, because a failing console.assert aborts under validateExceptionChecks on main (#44950).

Hostile values. An object that is inspected goes through the printers that Bun.inspect uses, so it shares their behavior one level down. A nested Error, Response or Request prints on several lines. A getter named $$typeof on the object, or size on a nested Map, runs, and its error reaches the caller. An object from another node:vm context prints the methods of its Object.prototype. 1.4.3 throws a TypeError for all of these.

The question for a maintainer. Node's util.format inspects every object whose toString is a built-in one. On the global console that changes text that prints today:

ValueTodayNode's rule with Bun's layout
{ a: 1 }[object Object]{ a: 1 }
[1, 2]1,2[ 1, 2 ]
[[1, 2], [3, 4]]1,2,3,4[ [Array ...], [Array ...] ]
new Date(1700000000000) in America/New_YorkTue Nov 14 2023 17:13:20 GMT-0500 (Eastern Standard Time)2023-11-14T22:13:20.000Z
new Map([[1, 2]])[object Map]Map(1) { 1: 2 }
-0, 42n0, 42-0, 42n

The WHATWG console says %s is String(value), which is today's text. Bun's util.format and new Console() already print Node's text. Draft #44833 takes Node's rule for test.each titles. The question: may the global console take Node's text for values that print today?

The branch robobun/b54fd54c/console-percent-s-node-text has an implementation on main. It is not proposed until there is an answer.

Self-review. 16 concerns were raised against the earlier version. 14 are addressed. Most fall away with the cut. The rule now checks valueOf, prints a module namespace, and inspects only plain objects and module namespaces. Tests compare 51 values with String(value) and 6 with Bun.inspect. Two are not addressed. A $$typeof getter on an object with no conversion still runs (the value threw before, and Bun.inspect runs it too). The array cap on one line (... N more items) is not changed in this PR.

The design for the values that throw is that of #34603, with the rule in one C++ function.