Skip to content

JSON dialect

@pbuilder/sdk/json edits .json and .jsonc files structurally. Instead of reading package.json, parsing it, and writing the whole file back with replaceContent, you address a value by its path and call a named op. Each op changes only the bytes it must: comments, key order, blank lines, indentation, line endings, a UTF-8 BOM, and the trailing newline all survive.

The handle works like every other dialect handle: ops chained on it coalesce into one write, and await follows the rules in How a dialect handle works.

package.json (before)
{
"name": "app"
}
factory.ts
import * as json from "@pbuilder/sdk/json";
export default async () => {
await json
.find("package.json")
.setValue(["scripts", "build"], "tsc")
.appendItem(["files"], "dist", { unique: true });
};
package.json (after)
{
"name": "app",
"scripts": {
"build": "tsc"
},
"files": [
"dist"
]
}

find(path) accepts a file name that ends in .json or .jsonc and has a name before the extension. Any other path, such as config.yaml, README, or a bare .json, throws synchronously. A file that is not valid JSON fails with could not parse "<path>"; comments and trailing commas are allowed. An empty file is a document with no value, and setValue creates its root.

The snippets in the rest of this page leave out the factory wrapper.

A path is an array of segments. A string segment names an object key, a number segment indexes an array, and [] is the document root:

["scripts", "build"] // the "build" key inside "scripts"
["plugins", 0] // the first item of "plugins"
[] // the whole document

Keys that contain dots or slashes need no escaping, because a path is never a joined string. Number segments must be non-negative integers.

Values are typed JsonValue: null, booleans, finite numbers, strings, arrays, and plain objects. Both JsonValue and JsonPath are exported as types from @pbuilder/sdk/json.

An as never cast or an untyped JavaScript factory can still pass any value past the type, so every value is also validated at runtime, before the document changes. These values reject, and the error names the path inside the value:

  • undefined, functions, symbols, and bigint
  • NaN and Infinity
  • class instances such as Date and Map
  • cyclic values
await json.find("config.json").setValue(["when"], new Date() as never);
// rejects: value at [] is a Date instance, which JSON cannot represent.

Every rejection is an error whose message starts with dialect operation failed: and names the op and path, for example:

dialect operation failed: addKey(["a"]) — the key already exists with a different value.

A rejected op emits no directive.

A path that does not fit the document also rejects: a segment that crosses a scalar, a number segment on an object, or a string segment on an array. Only setValue, merge, appendItem, updateValue, and the destinations of moveValue and copyValue create missing parents, and they only create objects. A path whose missing parent would have to be an array rejects.

Upserts the value at path, creating missing parent objects. setValue([], value) replaces the whole document. Setting a value deep-equal to the current one leaves the bytes unchanged.

// package.json before:
// {
// "name": "app"
// }
await json.find("package.json").setValue(["scripts", "build"], "tsc");
// -> {
// -> "name": "app",
// -> "scripts": {
// -> "build": "tsc"
// -> }
// -> }

options is { before?: string; after?: string }. It places a newly created key next to an existing sibling, with the same rules as addKey, and needs a path that ends in an object key.

Adds an object key that does not exist yet. When the key exists with a deep-equal value, the op is a no-op, so it is safe to re-run. When it exists with a different value, the op rejects. The parent object must exist.

{ before } or { after } places the new key next to a sibling. An absent sibling, or both options at once, reject. The option has no effect when the key already exists.

// package.json before:
// {
// "name": "app",
// "version": "1.0.0"
// }
await json.find("package.json").addKey(["private"], true, { after: "name" });
// -> {
// -> "name": "app",
// -> "private": true,
// -> "version": "1.0.0"
// -> }

Removes the member and its separator comma. An absent key is a no-op.

// config.json before: { "a": 1, "b": 2 }
await json.find("config.json").removeKey(["b"]);
// -> { "a": 1 }

Renames a key in place, keeping its value, position, and comments. When the old key is absent and newKey already exists, the key was already renamed and the op is a no-op. When both keys exist, or neither does, the op rejects.

// config.json before: { "a": 1, "b": 2, "c": 3 }
await json.find("config.json").renameKey(["b"], "beta");
// -> { "a": 1, "beta": 2, "c": 3 }

Every array op rejects when the value at path exists and is not an array.

Inserts value before the item at index. index equal to the array length appends. An index outside 0..length rejects.

// config.json before: { "x": [1, 3] }
await json.find("config.json").insertItem(["x"], 1, 2);
// -> { "x": [1, 2, 3] }

Appends value at the end, creating the array when it is absent. With { unique: true }, the op skips the append when a deep-equal item already exists, which makes it safe to re-run.

// config.json before: { "plugins": ["a"] }
await json.find("config.json").appendItem(["plugins"], "b", { unique: true });
// -> { "plugins": ["a", "b"] }

Removes the item at index and its separator. An out-of-range index rejects.

// config.json before: { "x": [1, 2] }
await json.find("config.json").removeItem(["x"], 0);
// -> { "x": [2] }

Removes every item deep-equal to value. No match, or nothing at path, is a no-op. A value at path that is not an array rejects.

// config.json before: { "x": ["a", "b", "a"] }
await json.find("config.json").removeValue(["x"], "a");
// -> { "x": ["b"] }

Replaces the item at index in place. An out-of-range index rejects. A deep-equal value is a no-op.

// config.json before: { "x": [1, 2] }
await json.find("config.json").replaceItem(["x"], 1, 9);
// -> { "x": [1, 9] }

Moves the item at index from so it sits at index to. The other items keep their relative order. Out-of-range indices reject.

// config.json before: { "x": [1, 2, 3] }
await json.find("config.json").moveItem(["x"], 0, 2);
// -> { "x": [2, 3, 1] }

Deep-merges a plain object into the object at path, creating it when absent. Two plain objects merge recursively. Any other pair, arrays included, is replaced by the incoming value. Keys that exist only in the document are kept, and members the merge does not change keep their bytes. Merging the same value again is a no-op. A target or value that is not a plain object rejects.

// config.json before: { "a": { "x": 1, "y": [1] }, "b": true }
await json.find("config.json").merge([], { a: { y: [2], z: 3 } });
// -> { "a": { "x": 1, "y": [2], "z": 3 }, "b": true }

Calls fn with the current value, or undefined when it is absent, and sets what fn returns. The returned value is validated like any other value.

// package.json before: { "version": 1 }
await json
.find("package.json")
.updateValue(["version"], (v) => (typeof v === "number" ? v + 1 : 1));
// -> { "version": 2 }

Moves a value, like the RFC 6902 move operation: removes it at from and writes it at to, replacing any value already there. A number last segment in to inserts into that array at that index; in from, it removes that item.

When from is absent and to exists, the value was already moved and the op is a no-op. The op rejects when both are absent, when to lies inside from, or when from is the document root.

// config.json before:
// {
// "a": {
// "x": 1
// },
// "b": {
// "y": 2
// }
// }
await json.find("config.json").moveValue(["a", "x"], ["b", "x"]);
// -> {
// -> "a": {},
// -> "b": {
// -> "y": 2,
// -> "x": 1
// -> }
// -> }

Copies a value with setValue’s rules, like the RFC 6902 copy operation. A deep-equal value already at to is a no-op. An absent from rejects.

// config.json before: { "a": [1] }
await json.find("config.json").copyValue(["a"], ["b"]);
// -> { "a": [1], "b": [1] }

Orders the object’s members by key, in code-point order. Each member keeps the comments and indentation that precede it. { recursive: true } also sorts every nested object, including objects inside arrays. An already sorted object is a no-op. A value that is not an object, or no value at all, rejects.

// config.json before: { "b": 1, "a": 2 }
await json.find("config.json").sortKeys([]);
// -> { "a": 2, "b": 1 }

JSONC comments (// and /* */) are preserved. In an object or array where each member ends its own line, a member owns the comment lines above it and the trailing comment on its last line. Removing the member drops them, sorting moves them with it, and inserting next to it never separates them from it:

// tsconfig.json before:
// {
// // second
// "b": 1, // trailing b
// "a": 2
// }
await json.find("tsconfig.json").sortKeys([]);
// -> {
// -> "a": 2,
// -> // second
// -> "b": 1 // trailing b
// -> }

There is no op to add, change, or remove a comment. Comment edits go through .modify().

New content uses the indentation detected from the document (tabs, or the width of the first indented line, falling back to two spaces) and the document’s own line ending. Inside a one-line container, edits stay on that line: { "a": 1 } gains , "b": 2 instead of being reformatted.

For comments, or any edit the named ops do not cover, .modify(fn) hands fn the live JsonDocument. It has one property, text: the current source, without the BOM, which is added back when the file is printed. jsonc-parser edits text rather than a mutable tree, so you compute edits with it and assign the result back to doc.text. Named ops chained after .modify() see the new text.

The dialect exports the complete jsonc-parser module as astLibrary, from the same entry point as find. You do not need to add jsonc-parser to your own dependencies.

Example: enable a compiler option with a comment

Section titled “Example: enable a compiler option with a comment”

This factory sets compilerOptions.strict with modify and applyEdits, then adds a comment above it. A re-run finds the comment and writes nothing new:

tsconfig.json (before)
{
// Shared compiler settings.
"compilerOptions": {
"target": "es2022"
}
}
factory.ts
import { find, astLibrary } from "@pbuilder/sdk/json";
const COMMENT = "// Required by the build pipeline.";
export default async () => {
await find("tsconfig.json").modify((doc) => {
const path = ["compilerOptions", "strict"];
const edits = astLibrary.modify(doc.text, path, true, {
formattingOptions: { insertSpaces: true, tabSize: 2 },
});
doc.text = astLibrary.applyEdits(doc.text, edits);
if (doc.text.includes(COMMENT)) return;
const property = astLibrary.findNodeAtLocation(astLibrary.parseTree(doc.text)!, path)!.parent!;
const lineStart = doc.text.lastIndexOf("\n", property.offset) + 1;
const indent = doc.text.slice(lineStart, property.offset);
doc.text = astLibrary.applyEdits(doc.text, [
{ offset: lineStart, length: 0, content: `${indent}${COMMENT}\n` },
]);
});
};
tsconfig.json (after)
{
// Shared compiler settings.
"compilerOptions": {
"target": "es2022",
// Required by the build pipeline.
"strict": true
}
}