1.0.1 β€’ Published 7 months ago

inspect-utils v1.0.1

Weekly downloads
-
License
-
Repository
-
Last release
7 months ago

🌺 inspect-utils 🌸

standard-readme compliant

πŸŒ…πŸŒΈ Gorgeous inspect output for your custom classes. πŸŒΊπŸŒ„

Table of Contents

Install

$ pnpm i inspect-utils

Motivation

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));

`Point {}`

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));

`Point { x: 1, y: 2 }`

Features

In addition to DisplayStruct, which creates inspect output with labelled values, there are multiple other styles of inspect output.

Tuples: Unlabeled Instances

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:

`SafeString('hello')`

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]);
  }
}

`SafeString('hello', 'checked')`

Units: Valueless Instances

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);
    }
  }
}

`CheckedString[unsafe]` and `CheckedString('hello')`

Descriptions

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",
        });
    }
  }
}

`SafeString('hello', 'checked')`

Annotations

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

`SafeString('hello', 'checked')`

πŸ“’ 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.

Display: Custom Formatting

You can also use the Display function to control the output format even more directly.

Declarative Use (The Whole Enchilada)

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 of Point.
  • Sets Symbol.toStringTag to Point on instances of Point.

Production-Friendly Builds by Default (Using Conditional Exports)

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.

Why Stripping Works: A Bit of a Deep Dive

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.

The debug Condition

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.

Maintainers

The Starbeam team.

Contributing

See the contributing file!

License

MIT Β© 2023 Yehuda Katz