π πΈ Gorgeous inspect output for your custom classes. πΊπ
$ pnpm i inspect-utils
Let's say you write a class that uses getters to define its main public interface:
class Point {
#x: number;
#y: number;
constructor(x: number, y: number) {
this.#x = x;
this.#y = y;
}
get x() {
return this.#x;
}
get y() {
return this.#y;
}
}
Since x
and y
are not data properties, the default Node inspect output is:
console.log(new Point(1, 2));
This is not very useful. Let's fix that:
import { DisplayStruct } from "inspect-utils";
class Point {
#x: number;
#y: number;
constructor(x: number, y: number) {
this.#x = x;
this.#y = y;
}
[Symbol.for("nodejs.util.inspect.custom")]() {
return DisplayStruct("Point", {
x: this.#x,
y: this.#y,
});
}
get x() {
return this.#x;
}
get y() {
return this.#y;
}
}
Now you get the inspect output you were expecting:
console.log(new Point(1, 2));
In addition to DisplayStruct
, which creates inspect output with labelled values, there are multiple other styles of inspect output.
If you have a class that represents a single internal value, representing the value as { label: value }
is too noisy.
In this case, you can use DisplayTuple
to create less verbose inspect output:
class SafeString {
#value: string;
[Symbol.for("nodejs.util.inspect.custom")]() {
return DisplayTuple("SafeString", this.#value);
}
}
Now, the inspect output is:
You can pass multiple values to DisplayTuple
as an array, and they will be comma-separated in the output.
class SafeString {
#value: string;
#verified: "checked" | "unchecked";
constructor(value: string, verified: "checked" | "unchecked") {
this.#value = value;
this.#verified = verified;
}
[Symbol.for("nodejs.util.inspect.custom")]() {
return DisplayTuple("SafeString", [this.#value, this.#verified]);
}
}
If you have an instance that represents a singleton value, you can use DisplayUnit
to create even less verbose inspect output.
You can use descriptions with unit-style inspect output. You can also use unit-style inspect output for certain instances and more verbose inspect output for others.
import { DisplayStruct } from "inspect-utils";
type CheckResult =
| { verification: "unsafe" }
| { verification: "safe"; value: string };
class CheckedString {
static UNSAFE = new CheckedString({ verification: "unsafe" });
static safe(value: string): CheckedString {
return new CheckedString({ verification: "safe", value });
}
#value: CheckResult;
constructor(value: CheckResult) {
this.#value = value;
}
[Symbol.for("nodejs.util.inspect.custom")]() {
switch (this.#value.verification) {
case "unsafe":
return DisplayUnit("CheckedString", { description: "unsafe" });
case "safe":
return DisplayTuple("CheckedString", this.#value.value);
}
}
}
If you have a single class with multiple logical sub-types, you can add a description to the inspect output:
import { DisplayStruct } from "inspect-utils";
class Async<T> {
#value:
| { status: "pending" }
| { status: "fulfilled"; value: T }
| { status: "rejected"; reason: Error };
constructor(value: Promise<T>) {
this.#value = { status: "pending" };
value
.then((value) => {
this.#value = { status: "fulfilled", value };
})
.catch((reason) => {
this.#value = { status: "rejected", reason };
});
}
[Symbol.for("nodejs.util.inspect.custom")]() {
switch (this.#value.status) {
case "pending":
return DisplayUnit("Async", { description: "pending" });
case "fulfilled":
return DisplayTuple("Async", this.#value.value, {
description: "fulfilled",
});
case "rejected":
return DisplayTuple("Async", this.#value.reason, {
description: "rejected",
});
}
}
}
Descriptions are useful to communicate that the different sub-types are almost like different classes, so they appear as labels alongside the class name itself.
Annotations, on the other hand, provide additional context for the value.
Let's see what would happen if we used annotations instead of descriptions for the async example.
import { DisplayStruct } from "inspect-utils";
class Async<T> {
#value:
| { status: "pending" }
| { status: "fulfilled"; value: T }
| { status: "rejected"; reason: Error };
constructor(value: Promise<T>) {
this.#value = { status: "pending" };
value
.then((value) => {
this.#value = { status: "fulfilled", value };
})
.catch((reason) => {
this.#value = { status: "rejected", reason };
});
}
[Symbol.for("nodejs.util.inspect.custom")]() {
switch (this.#value.status) {
case "pending":
return DisplayUnit("Async", { description: "pending" });
case "fulfilled":
return DisplayTuple("Async", this.#value.value, {
- description: "fulfilled",
+ annotation: "@fulfilled",
});
case "rejected":
return DisplayTuple("Async", this.#value.reason, {
- description: "rejected",
+ annotation: "@rejected",
});
}
}
}
In this case, the inspect output would be
π The unit style does not support annotations because annotations appear alongside the structure's value and the unit style doesn't have a value.
The decision to use descriptions or annotations is stylistic. Descriptions are presented as important information alongside the class name, while annotations are presented in a dimmer font alongside the value.
You can also use the Display
function to control the output format even more directly.
import { inspect } from "inspect-utils";
class Point {
static {
inspect(this, (point) =>
DisplayStruct("Point", {
x: point.#x,
y: point.#y,
}),
);
}
#x: number;
#y: number;
constructor(x: number, y: number) {
this.#x = x;
this.#y = y;
}
}
This does two things:
- Automatically installs the
Symbol.for("nodejs.util.inspect.custom")
on instances ofPoint
. - Sets
Symbol.toStringTag
toPoint
on instances ofPoint
.
If you are using a tool that understands conditional exports, using the declarative API above will automatically strip out the custom display logic when the "production" condition is defined.
Vite directly supports the
"production"
condition, and enables it whenever the Vite mode is"production"
The default condition includes import.meta.env.DEV
checks, and is suitable for builds that know how to replace import.meta.env.DEV
but don't resolve conditional exports properly.
This strategy assumes that you are using a minifier like terser in a mode that strips out no-op functions.
When the production
export is resolved, the inspect
function looks like this:
export function inspect(Class, inspect) {}
When using a function like that in standard minifiers with default compression settings, the call to the function, including callback parameters, is eliminated.
Check out this example in the swc playground.
Pasting the same code into the [terser playground] with default settings yields this output:
export class Point {
static {}
#t;
#s;
constructor(t, s) {
(this.#t = t), (this.#s = s);
}
}
Unfortunately, both terser and swc leave in empty static blocks at the moment. Hopefully this will be fixed in the future. In the meantime, the default behavior of this library with a minifier is to completely remove all custom inspect logic, which is the meat of the matter.
A reasonable bundler (such as rollup) should also avoid including any of inspect-utils
's display logic in production, since you only use the inspect
function directly, and the inspect function doesn't use any of the rest of inspect-utils
's code in the production export.
inspect-utils
also provides an export for the debug-symbols
condition, which does not strip out the custom display logic and is intended to be compatible with the production
condition.
To use this, you will need to configure your environment with a "debug-symbols"
condition that is higher priority than the "production"
condition.
MIT Β© 2023 Yehuda Katz