Building a JSON Tools Package from Scratch in TypeScript
Why I Built @gagandeep023/json-view
Every backend engineer has the same workflow: copy JSON from a log, paste it into some online formatter, squint at the output, then repeat. I wanted a tool I could trust. Most online JSON formatters send your data to a server. If you are working with production logs that contain user tokens or API keys, that is a problem.
The constraint I set: zero dependencies, zero network requests, runs entirely in the browser. The package itself has no runtime dependencies. It exports pure functions that take strings in and return structured results. I also wanted to publish it as a real npm package, not just a page on my portfolio.
Package Architecture
@gagandeep023/json-view
|
+-- src/types.ts Public interfaces
| FormatOptions, FormatResult
| DiffNode, DiffResult
| ParseStringifiedResult
|
+-- src/formatter.ts Format / Minify / Validate
| validateJSON(input) --> FormatResult
| formatJSON(input, opts?) --> FormatResult
| minifyJSON(input) --> FormatResult
|
+-- src/diff.ts Deep recursive diff
| diffJSON(old, new) --> DiffResult
| flattenDiff(changes) --> DiffNode[]
|
+-- src/stringifyParser.ts Un-stringify nested JSON
| parseStringified(input) --> ParseStringifiedResult
|
+-- src/index.ts Barrel exports
|
+-- dist/
index.js (CJS)
index.mjs (ESM)
index.d.ts (Types)Each module is independent. The formatter handles parsing, beautifying, and minifying. The differ does recursive comparison of two parsed values. The stringify parser unwraps doubly or triply escaped JSON strings. All three share a common pattern: take input, return a result object with success, data, and error fields. No exceptions thrown to the caller.
The Formatter: Error Location Extraction
JSON.parse throws a SyntaxError with a message like "Unexpected token } in JSON at position 15". That position number is useful but not user-friendly. I needed line and column numbers for the UI to show where the error is.
function extractErrorLocation(
message: string,
input: string
): { line: number; column: number } | undefined {
const posMatch = message.match(/position\s+(\d+)/i);
if (posMatch) {
const pos = parseInt(posMatch[1], 10);
let line = 1;
let column = 1;
for (let i = 0; i < pos && i < input.length; i++) {
if (input[i] === '\n') {
line++;
column = 1;
} else {
column++;
}
}
return { line, column };
}
return undefined;
}The function regex-extracts the character position from the error message, then walks the input string counting newlines to convert the flat position into line:column. This is a single O(n) pass. The regex pattern handles both V8 and SpiderMonkey error message formats.
Key sorting is another feature. When you format JSON with `sortKeys: true`, the function recursively traverses the parsed value and rebuilds every object with sorted keys. Arrays preserve their order since index positions are meaningful.
The Differ: Recursive Comparison with Path Tracking
The diff algorithm recursively compares two parsed JSON values and produces a flat list of changes. Each change has a dot-notation path (like `user.address.city`), a type (added, removed, modified), and the old/new values.
function diffValues(
oldVal: unknown,
newVal: unknown,
path: string,
depth: number
): DiffNode[] {
if (depth > MAX_DEPTH) return [];
const oldType = typeOf(oldVal);
const newType = typeOf(newVal);
// Type mismatch = modified
if (oldType !== newType) {
return [{ path, type: 'modified', oldValue: oldVal, newValue: newVal }];
}
// Recurse into objects
if (oldType === 'object' && oldVal !== null && newVal !== null) {
return diffObjects(oldObj, newObj, path, depth);
}
// Recurse into arrays
if (oldType === 'array') {
return diffArrays(oldArr, newArr, path, depth);
}
// Primitives: simple equality check
if (oldVal !== newVal) {
return [{ path, type: 'modified', oldValue: oldVal, newValue: newVal }];
}
return [];
}The path builder handles three cases. Object keys that match `[a-zA-Z_$][a-zA-Z0-9_$]*` get dot notation (`user.name`). Special characters get bracket notation (`["my-key"]`). Array indices always get brackets (`items[0]`). A depth limit of 50 prevents stack overflow on deeply nested or circular-like structures.
For objects, the algorithm takes the union of keys from both sides, then classifies each key: present only in old = removed, present only in new = added, present in both = recurse deeper. For arrays, it iterates up to the max length of both, treating indices beyond either array's length as additions or removals.
The Stringify Parser: Iterative Unwrapping
This one solves a surprisingly common problem. When JSON gets stored as a string column in a database, or passed through multiple serialization layers, you end up with something like `"{\"key\":\"value\"}"`. One layer of escaping. Sometimes two. Sometimes three.
export function parseStringified(input: string): ParseStringifiedResult {
let current: unknown = JSON.parse(input);
let nestingLevel = 0;
while (typeof current === 'string' && nestingLevel < MAX_NESTING) {
try {
current = JSON.parse(current);
nestingLevel++;
} catch {
break; // String is not valid JSON, stop unwrapping
}
}
return { success: true, parsed: current, nestingLevel };
}The approach is simple: parse once, check if the result is still a string, parse again, repeat until you get a non-string or hit the max nesting depth of 10. The nesting level tells the user how many layers of stringify were applied. The try/catch in the loop handles the case where a plain string value (not JSON) is the final result.
Frontend Integration
The package is consumed in a React page with three tabs: Format, Diff, and Stringify. All processing happens on button click, not on every keystroke. This avoids performance issues with large JSON payloads.
import { formatJSON, diffJSON, parseStringified } from '@gagandeep023/json-view';
// Format tab
const result = formatJSON(input, { indent: 2, sortKeys: true });
if (result.success) setOutput(result.formatted);
else setError(result.error);
// Diff tab
const diff = diffJSON(JSON.parse(left), JSON.parse(right));
// diff.changes is an array of { path, type, oldValue, newValue }
// Stringify tab
const parsed = parseStringified(input);
// parsed.nestingLevel tells you how deep it was stringifiedThe diff results are color-coded: green for additions, red for removals, yellow for modifications. Each change shows the dot-notation path and the old/new values truncated to 80 characters. The format tab includes a CodeEditor component with a line numbers gutter that syncs its scroll position with the textarea.
Testing Strategy
The package has 48 tests across three test files. I used Vitest for its speed and native TypeScript support. The tests cover happy paths, edge cases (empty input, whitespace, unicode), and error conditions.
- Formatter: 20 tests covering beautify, minify, validate, sort keys, custom indent, error location extraction, primitives, arrays, and unicode
- Differ: 17 tests covering added/removed/modified properties, nested paths, array changes, type mismatches, null handling, special-character keys, and root-level primitives
- Stringify parser: 11 tests covering single/double/triple nesting, passthrough, arrays, primitives, invalid input, and max depth protection
Build and Publish Pipeline
The package uses tsup to generate three outputs: CommonJS (`index.js`), ESM (`index.mjs`), and TypeScript declarations (`index.d.ts`). The package.json exports map ensures bundlers resolve the right format. The `types` condition comes first in the exports map so TypeScript resolves declarations before runtime modules.
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
}
}The `.npmignore` keeps source files, test files, and config files out of the published package. Only `dist/`, `package.json`, `README.md`, and `LICENSE` ship to npm.
Tradeoffs and Lessons
- Zero dependencies is a hard constraint but worth it. The package is 6 KB minified. No supply chain risk, no version conflicts.
- The diff algorithm is O(n) for the total number of leaf values across both inputs. For very large JSON (10,000+ keys), this can be slow. A production differ would use hashing to skip identical subtrees.
- Error location extraction depends on regex-matching the engine's error message format. This works for V8 and SpiderMonkey but could break on other engines. A custom JSON parser would be more reliable but adds significant complexity.
- Path notation with dots and brackets mirrors JavaScript property access syntax. This makes diff output directly useful: you can paste a path into your debugger's console to inspect the exact value.
The package is published on npm as @gagandeep023/json-view and the source code is on GitHub at Gagandeep023/json-view. The interactive tool runs at /json-view on this portfolio. All processing is client-side. Your JSON never leaves your browser.
Get more posts like this
I write about system design, backend engineering, and building npm packages from scratch. Follow along on Substack for new posts.
Subscribe on Substack →