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 ${key} → ${value} and join all those strings together with a comma:


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.

Table of Contents:
  1. JavaScript (V8) in Ableton Live Overview
  2. Getting Started
  3. Real Time MIDI Processing
  4. The Max Console
  5. The Live API
  6. Generating MIDI Clips
  7. Max Console Part 2: Better Logging for Objects