Max Console Part 2: Better Logging for Objects
This is a follow-up to the JavaScript (V8) in Live tutorial "The Max Console".
The v8
object supports
all the standard built-in object in modern JavaScript.
This includes data structures like
Map and
Set, which are very useful. For
example, we used Map
in "Real Time MIDI Processing".
Unfortunately our logging code isn't handling them very well. Let's take a look.
Note: I am omitting our error()
function as we work some improvements. I'll show it
again at the end of this article.
const toString = (any) => {
const s = String(any);
if(s.includes("[object Object]")) {
return JSON.stringify(any);
}
return s;
}
const log = (...any) => post(...any.map(toString), "\n");
log("------------------------------");
log(new Date());
log(new Map([["key1", "value1"], ["key2", "value2"]]));
log(new Set([1, 1, 2, 3, 2]));
We can see we have a Map
and a Set
, but can't see any
of their contents:
[object Map] [object Set]
My first attempt was to use JSON.stringify()
on these any time we see
"[object "
(instead of "[object Object]"
):
const toString = (any) => {
const s = String(any);
if(s.includes("[object ")) {
return JSON.stringify(any);
}
return s;
}
But this makes things worse:
{} {}
Now we can't even tell they are a Map
and a Set
. Part
of the problem is a Map
can use anything as a key, but JSON requires all keys to be
strings. So there's no fully automatic way to convert a Map
to JSON. I suspect the
reason Set
doesn't automatically convert to JSON is because it would be
indistinguishable from an Array. It is up to you the developer to convert Map
and
Set
to JSON the way you want to by writing custom code (or using a third-party
library/framework).
One thing that helps is printing the type of object we have. The way to do that in JavaScript is with an object's
constructor.name
:
const toString = (any) => {
const s = String(any);
if(s.includes("[object ")) {
return any.constructor.name + JSON.stringify(any);
}
return s;
}
Now we can distinguish Map
and Set
again, but we still
can't see their contents:
Map{} Set{}
We have been using JSON as a shortcut for converting to strings. Now it is getting in the way. Let's stop trying to use
JSON here and focus on how we want to convert Map
and
Set
to strings directly.
Pretty Printing Set
and Array
We can add special case logic for any types we want to "pretty print". How do we setup conditional logic in JavaScript based on a variable's type? JavaScript uses an object prototype system to provide object-oriented-style programming. So an object's prototype represents which type of object it is.
We can get the object's prototype with
Object.getPrototypeOf(any),
but we have to be careful because it throws an exception if you call it with null
or
undefined
. We can default null
and
undefined
to something that will return a reasonable value using
nullish coalescing. If
you call Object.getPrototypeOf()
with a prototype instead of an object, it returns
null
, which seems like a good default behavior. In summary, we can get the type of a
JavaScript object with:
Object.getPrototypeOf(any ?? Object.prototype)
We can use this expression to switch on an object's type and special case Set
to return
a custom string representation:
const toString = (any) => {
switch(Object.getPrototypeOf(any ?? Object.prototype)) {
case Set.prototype:
const array = [...any];
const commaSeparatedList = array.join(", ");
return `Set(${commaSeparatedList})`;
}
const s = String(any);
if(s.includes("[object Object]")) {
return any.constructor.name + JSON.stringify(any);
}
return s;
}
const log = (...any) => post(...any.map(toString), "\n");
log(new Map([["key1", "value1"], ["key2", "value2"]]));
log(new Set([1, 1, 2, 3, 2]));
I also changed s.includes("[object ]")
back to s.includes("[object Object]")
because it works better in a lot of cases. We'll leave the any.constructor.name
there because it has some benefits that we'll see in the final round of testing.
The expression [...any]
is an idiom
in JavaScript for converting any iterable object to an array. Once we have an array, it is straightforward to display it
as a string using functions like Array's join()
. I decided to format it like this:
[object Map] Set(1, 2, 3)
The code above was written for clarity but it's a little verbose. We can write it more compactly like:
const toString = (any) => {
switch(Object.getPrototypeOf(any ?? Object.prototype)) {
case Set.prototype:
return `Set(${[...any].join(", ")})`;
}
const s = String(any);
if(s.includes("[object Object]")) {
return any.constructor.name + JSON.stringify(any);
}
return s;
}
While we're here, I don't love that things like log([1,2,3])
display as
1,2,3
. We can add a special case arrays to make it clearer they are arrays:
const toString = (any) => {
switch(Object.getPrototypeOf(any ?? Object.prototype)) {
case Array.prototype:
return `[${any.join(", ")}]`;
case Set.prototype:
return `Set(${[...any].join(", ")})`;
}
const s = String(any);
if(s.includes("[object Object]")) {
return any.constructor.name + JSON.stringify(any);
}
return s;
}
const log = (...any) => post(...any.map(toString), "\n");
log(new Map([["key1", "value1"], ["key2", "value2"]]));
log(new Set([1, 1, 2, 3, 2]));
log([1,2,3]);
[object Map] Set(1, 2, 3) [1, 2, 3]
Looking good! But what happens with Arrays inside Arrays, Sets inside Arrays, Sets inside Sets, etc?
log(new Set([1, 1, [2, 3], new Set([2])]));
log([1,[2,[3]]]);
Set(1, 2,3, [object Set]) [1, 2,3]
It's not quite what I was hoping for. Recursion to the rescue:
const toString = (any) => {
switch(Object.getPrototypeOf(any ?? Object.prototype)) {
case Array.prototype:
return `[${any.map(toString).join(", ")}]`;
case Set.prototype:
return `Set(${[...any].map(toString).join(", ")})`;
}
const s = String(any);
if(s.includes("[object Object]")) {
return any.constructor.name + JSON.stringify(any);
}
return s;
}
const log = (...any) => post(...any.map(toString), "\n");
log(new Map([["key1", "value1"], ["key2", "value2"]]));
log(new Set([1, 1, [2, 3], new Set([2])]));
log([1,[2,3]]);
Similar to how we handle the list of parameters to log()
, we use
map()
to call toString()
on every element in an
Array
or Set
. Even if we have an
Array
that contains an Array
that contains another
Array
, it will recursively call toString()
all the way down to
the innermost elements and ensure everything is converted to strings properly:
[object Map] Set(1, [2, 3], Set(2)) [1, [2, [3]]]
It's really looking good!
Pretty Printing Map
and Object
We have a good structure in place and now it's mostly a matter of adding more special cases for any object we want to
pretty print. Let's handle Map
and improve general Object
printing along the way (similar to how working through Set
helped improve
Array
).
A Map
can return a list of entries via it's entries()
method.
These are "key-value pairs" in the format [["key1", "value1"], ["key2", "value2"]]
, which
is the same format the Map
constructors takes (when we create a map with
new
).
The return value of a Map's entries()
is actually an iterator, so similar to what we did
with Set
, we need to convert it to an array with
[...any.entries()]
. Then we can convert every ["key", "value"]
pair to a string
and join all those strings together with a comma:${key} → ${value}
const toString = (any) => {
switch(Object.getPrototypeOf(any ?? Object.prototype)) {
case Array.prototype:
return `[${any.map(toString).join(", ")}]`;
case Set.prototype:
return `Set(${[...any].map(toString).join(", ")})`;
case Map.prototype:
const entries = [...any.entries()];
const m = entries.map(([k,v]) => `${k} → ${v}`).join(", ");
return `Map(${m})`;
}
const s = String(any);
if(s.includes("[object Object]")) {
return any.constructor.name + JSON.stringify(any);
}
return s;
}
const log = (...any) => post(...any.map(toString), "\n");
log(new Map([["key1", "value1"], ["key2", "value2"]]));
log(new Map([["key1", "value1"], [new Set(["key2"]), ["val2", "val3"]]]));
log(new Set([1, 1, [2, 3], new Set([2])]));
log([1,[2,[3]]]);
Similar to our first pass for Set
, we have the basic case working, but it's not
handling nested data structures well:
Map(key1 → value1, key2 → value2) Map(key1 → value1, [object Set] → val2,val3) Set(1, [2, 3], Set(2)) [1, [2, [3]]]
And similar to how we solved this problem with Set
, we can recursively call
toString()
on all the keys and the values in the Map
:
const toString = (any) => {
switch(Object.getPrototypeOf(any ?? Object.prototype)) {
case Array.prototype:
return `[${any.map(toString).join(", ")}]`;
case Set.prototype:
return `Set(${[...any].map(toString).join(", ")})`;
case Map.prototype:
const m = [...any.entries()]
.map(([k,v]) => `${toString(k)} → ${toString(v)}`)
.join(", ");
return `Map(${m})`;
}
const s = String(any);
if(s.includes("[object Object]")) {
return any.constructor.name + JSON.stringify(any);
}
return s;
}
const log = (...any) => post(...any.map(toString), "\n");
log(new Map([["key1", "value1"], ["key2", "value2"]]));
log(new Map([["key1", "value1"], [new Set(["key2"]), ["value2", "value3"]]]));
log(new Set([1, 1, [2, 3], new Set([2])]));
log([1,[2,3]]);
Map(key1 → value1, key2 → value2) Map(key1 → value1, Set(key2) → [value2, value3]) Set(1, [2, 3], Set(2)) [1, [2, 3]]
To handle JavaScript objects generally, we do something very similar and use
Object.entries(any)
to get the entries. I separated the key and value with a
:
so it prints like a normal JavaScript object literal:
const toString = (any) => {
switch(Object.getPrototypeOf(any ?? Object.prototype)) {
case Array.prototype:
return `[${any.map(toString).join(", ")}]`;
case Set.prototype:
return `Set(${[...any].map(toString).join(", ")})`;
case Object.prototype:
const obj = Object.entries(any)
.map(([k,v]) => `${toString(k)}: ${toString(v)}`)
.join(", ");
return `{${obj}}`;
case Map.prototype:
const m = [...any.entries()]
.map(([k,v]) => `${toString(k)} → ${toString(v)}`)
.join(", ");
return `Map(${m})`;
}
const s = String(any);
if(s.includes("[object Object]")) {
return any.constructor.name + JSON.stringify(any);
}
return s;
}
const log = (...any) => post(...any.map(toString), "\n");
log(new Map([["key1", "value1"], ["key2", "value2"]]));
log(new Map([["key1", "value1"], [new Set(["key2"]), ["value2", "value3"]]]));
log(new Set([1, 1, [2, 3], new Set([2])]));
log([1,[2,3]]);
log({
map: new Map([['key', 'val']]),
set: new Set([1]),
obj: { array: [1,2,3] },
});
If we tried logging that last line before handling case Object.prototype
, it looks like
this:
Object{"map":{},"set":{},"obj":{"array":[1,2,3]}}
and after we add the case Object.prototype
logic, it looks like:
{map: Map(key → val), set: Set(1), obj: {array: [1, 2, 3]}}
Nice.
Cleanup and the Final Test
To wrap up, I want to shorten the implementation. I decided to rename toString()
to
str()
. I removed a bunch of newlines and made things more compact. These decisions are
subjective, so if you don't like it, feel free to adjust the code the way you want for your own projects.
Now that we have good handling of built-in objects and nested data structures, and we can add support for more objects
as needed, I changed the condition s.includes("[object Object]")
to the more specific
condition s === "[object Object]"
. This means any object that doesn't have
custom "toString" logic (in the switch
block) will automatically convert to JSON and have
its constructor prepended.
Here is my final version of the logging code. I've included the error()
function again, and
I also wrapped it up in console.log()
and console.error()
for
anyone who are used to that interface (for example, when copy and pasting some open source project or using AI-generated
code):
const str = (any) => {
switch(Object.getPrototypeOf(any ?? Object.prototype)) {
case Array.prototype: return `[${any.map(str).join(", ")}]`;
case Set.prototype: return `Set(${[...any].map(str).join(", ")})`;
case Object.prototype: return `{${Object.entries(any).map(([k,v]) =>`${str(k)}: ${str(v)}`).join(", ")}}`;
case Map.prototype: return `Map(${[...any.entries()].map(([k,v]) =>`${str(k)} → ${str(v)}`).join(", ")})`;
}
const s = String(any);
return s === "[object Object]" ? any.constructor.name + JSON.stringify(any) : s;
}
const log = (...any) => post(...any.map(str), "\n");
const error = (...any) => globalThis.error(...any.map(str), "\n");
const console = {log, error};
Let's test it thoroughly:
class SimpleClass {}
class MyClass {
toString() { return "MyClassInstance"; }
}
log("------------------------------------------------\n", new Date());
log("Hello");
log(2);
log(3.14159265359);
log(Infinity);
log(NaN);
log(true);
log(false);
log(null);
log(undefined);
log();
log([1, 2, 3]);
log({ a: 1, b: 2 });
log([1, { a: { b: true } }]);
log(Symbol("$"));
log();
log(new Map([["key1", "value1"], ["key2", "value2"]]));
log(new Map([["key1", "value1"], [new Set(["key2"]), ["value2", "value3"]]]));
log(new Set([1, 1, [2, 3], new Set([2])]));
log([1,[2,3]]);
log({
map: new Map([['key', 'val']]),
set: new Set([1]),
obj: { array: [1,2,3] },
});
log();
// and here's some extra stuff we didn't talk about:
log(`multi
line`);
log(BigInt("1234567890123456789012345678901234567890"));
log(/regexp/);
log(Error("kaboom"));
log(Promise.resolve());
log((x) => Math.sqrt(x));
log(new SimpleClass());
log(SimpleClass);
log(new MyClass());
log({sc: new SimpleClass(), mc: new MyClass()});
log("Live API Test:");
log(new LiveAPI('live_set').info);
------------------------------------------------ Fri Dec 06 2024 18:29:23 GMT-0800 (Pacific Standard Time) Hello 2 3.14159265359 Infinity NaN true false null undefined [1, 2, 3] {a: 1, b: 2} [1, {a: {b: true}}] Symbol($) Map(key1 → value1, key2 → value2) Map(key1 → value1, Set(key2) → [value2, value3]) Set(1, [2, 3], Set(2)) [1, [2, 3]] {map: Map(key → val), set: Set(1), obj: {array: [1, 2, 3]}} multi line 1234567890123456789012345678901234567890 /regexp/ Error: kaboom [object Promise] (x) => Math.sqrt(x) SimpleClass{} class SimpleClass {} MyClassInstance {sc: SimpleClass{}, mc: MyClassInstance} Live API Test: id 1 type Song description This class represents a Live set. ...
Next Steps
Well, shucks. You ran out of JavaScript in Live tutorials. Thanks for reading! I hope you learned a lot and are better equipped to go build cool stuff. Do that. Build cool stuff. Or go outside or something. How long have you been staring at a computer?
Check back later, I might add some more articles on advanced topics, such as building MIDI Tools.