doctor-json
v1.0.0
Published
Surgically edit JSON & JSONC strings while preserving whitespace, comments, and formatting
Maintainers
Readme
doctor-json
Surgically edit JSON & JSONC strings — preserving whitespace, comments, and formatting.
Why?
JSON files have formatting that matters — comments, indentation style, trailing commas, hand-organized sections. JSON.parse + JSON.stringify destroys all of it.
Doctor JSON lets you edit JSON like a normal object. When you stringify, only what you changed is different. Everything else is byte-identical.
import { parse, stringify } from 'doctor-json'
const config = parse(tsconfigText)
config.compilerOptions.target = 'ES2024'
await fs.writeFile('tsconfig.json', stringify(config))With JSON.stringify, one field change destroys the entire file:
{"compilerOptions":{"target":"ES2024","strict":true},"include":["src"]}With Doctor JSON, only the value you touched is different:
{
// Compiler options
"compilerOptions": {
"target": "ES2024", // latest stable
"strict": true,
},
"include": ["src"]
}Install
npm install doctor-jsonUsage
parse() returns a plain JavaScript object. Mutate it with standard JS. stringify() diffs your changes against the original and patches the text.
import { parse, stringify, sortKeys, rename } from 'doctor-json'
const pkg = parse(text)
pkg.version = '2.0.0'
pkg.keywords.push('json', 'ast')
delete pkg.deprecated
sortKeys(pkg.dependencies)
rename(pkg.scripts, 'build', 'compile')
const result = stringify(pkg)No proxies, no special APIs. Array.isArray, Object.keys, for...of, spread, destructuring — everything works natively because it's a real object.
Examples
Edit a package.json with formatting
Real package.json files often have tabs, blank-line section separators, and "// comment" keys as comment workarounds. Doctor JSON preserves all of it:
import { parse, stringify, sortKeys, rename } from 'doctor-json'
const pkg = parse(packageJsonText)
pkg.version = '2.0.0'
rename(pkg.scripts, 'build', 'compile')
pkg.dependencies.pinia = '^2.1.0'
sortKeys(pkg.dependencies)
await fs.writeFile('package.json', stringify(pkg))
// Tabs, blank-line groups, "// comment" keys — all preservedSee examples/package-json.ts for the full before/after with tabs, grouped sections, and comment keys.
Update a tsconfig.json (JSONC)
Comments and trailing commas survive all operations:
const config = parse(tsconfigText)
config.compilerOptions.target = 'ES2024'
config.compilerOptions.noUncheckedIndexedAccess = true
config.exclude.push('coverage')
// Line comments, block comments, trailing commas — all preservedSee examples/tsconfig-jsonc.ts for a full JSONC editing example.
Rename a key (preserving position and comments)
rename changes the key name without moving it or losing its surrounding formatting:
// Before
{
"scripts": {
// Compile TypeScript
"build": "tsc",
"test": "vitest"
}
}rename(pkg.scripts, 'build', 'compile')// After
{
"scripts": {
// Compile TypeScript
"compile": "tsc",
"test": "vitest"
}
}Only the key name changed. The comment, value, and position are all preserved. With delete + re-add, the key moves to the end and the comment is lost.
More examples
API
import { parse, stringify, sortKeys, rename } from 'doctor-json'parse(text)
Parse a JSON/JSONC string. Returns a plain JavaScript object.
stringify(obj)
Produce the edited text. Unchanged content keeps its original formatting, comments, and whitespace.
const result = stringify(pkg)
await fs.writeFile('package.json', result)sortKeys(obj, comparator?)
Sort object keys. Comments travel with their keys. Blank lines between members create independent sort groups — members never cross group boundaries.
sortKeys(pkg.dependencies) // alphabetical
sortKeys(pkg, (a, b) => customOrder(a, b)) // custom comparatorrename(obj, oldKey, newKey)
Rename a key in place. Position, value, and surrounding comments are preserved.
rename(pkg.scripts, 'build', 'compile')Behavior
Formatting preservation
Doctor JSON detects formatting per-object — indentation style, colon spacing, inline vs multiline, trailing commas. New content matches the style of the object it's inserted into.
// Minified input → minified output
parse('{"a":1}').b = 2 // → '{"a":1,"b":2}'
// 4-space indent → 4-space output
parse('{\n "a": 1\n}') // new keys get 4-space indentJSONC support
Comments and trailing commas are preserved through all operations, including comments between key and value:
const config = parse('{"key": /* important */ "old"}')
config.key = 'new'
stringify(config) // '{"key": /* important */ "new"}'Comment association
When sorting or removing members, comments travel with their associated member:
- Same-line comments (
// noteafter a value) stay with that member - Above-line comments (comment on the line above) stay with the member below
To pin a comment as a section header that doesn't move during sort, separate it with a blank line.
Sort groups
Blank lines between members create independent sort groups. sortKeys sorts within each group but never moves members across group boundaries:
{
// These two sort together
"b": 1,
"a": 2,
// These two sort together (separately)
"d": 3,
"c": 4
}After sortKeys: a, b in group 1, c, d in group 2. The blank line keeps them apart.
Notes
stringify(pkg)is the surgical output.JSON.stringify(pkg)re-serializes from scratch (comments and formatting lost).parse()returns plain objects with normal prototypes —instanceof Object,hasOwnProperty, andtoStringall work.- Duplicate keys use last-key-wins (matching
JSON.parse). - Value coercion follows
JSON.stringifysemantics —DatecallstoJSON(),undefined/functions are omitted,NaN/Infinitybecomenull.
How it works
1. parse(text)
├─ Parse text → AST (preserves comments, whitespace)
├─ Evaluate AST → plain JS object
└─ Snapshot the object state
2. Mutate with normal JS
obj.key = 'new value'
3. stringify(obj)
├─ Diff current object vs snapshot → find what changed
├─ Patch only the changed parts in the original text
└─ Return the patched textUnchanged text is never touched, so formatting, comments, and whitespace survive.
