@winglet/json
Version:
TypeScript library for safe and efficient JSON data manipulation with RFC 6901 (JSON Pointer) and RFC 6902 (JSON Patch) compliance, featuring prototype pollution protection and immutable operations
640 lines (482 loc) • 17.8 kB
Markdown
# @winglet/json — Specification
**Version**: 0.10.0
**Standards**: RFC 6901 (JSON Pointer), RFC 6902 (JSON Patch), RFC 7396 (JSON Merge Patch)
---
## Table of Contents
1. [Installation](#installation)
2. [Quick Start](#quick-start)
3. [Sub-path Imports](#sub-path-imports)
4. [JSON Pointer](#json-pointer)
- [getValue](#getvalue)
- [setValue](#setvalue)
- [escapePath / unescapePath](#escapepath--unescapepath)
- [escapeSegment](#escapesegment)
- [convertJsonPointerToPath](#convertjsonpointertopath)
5. [JSON Patch](#json-patch)
- [compare](#compare)
- [applyPatch](#applypatch)
- [difference](#difference)
- [mergePatch](#mergepatch)
6. [JSON Path](#json-path)
- [getJSONPath](#getjsonpath)
- [convertJsonPathToPointer](#convertjsonpathtopointer)
7. [Type Definitions](#type-definitions)
8. [Security](#security)
9. [Error Handling](#error-handling)
10. [Performance](#performance)
---
## Installation
```bash
npm install @winglet/json
yarn add @winglet/json
pnpm add @winglet/json
```
**Requirements**: Node.js 14.0.0+ or a modern browser with ES2020 support.
---
## Quick Start
```typescript
import { getValue, setValue, compare, applyPatch } from '@winglet/json';
const document = {
users: [
{ id: 1, name: 'Alice', role: 'admin' },
{ id: 2, name: 'Bob', role: 'user' },
],
settings: { theme: 'dark', language: 'en' },
};
// Read a value using JSON Pointer
const theme = getValue(document, '/settings/theme');
// 'dark'
// Write a value (mutates in place)
setValue(document, '/settings/theme', 'light');
// Generate a diff
const patches = compare(
{ name: 'Alice', age: 30 },
{ name: 'Alice', age: 31, city: 'Seoul' },
);
// [
// { op: 'replace', path: '/age', value: 31 },
// { op: 'add', path: '/city', value: 'Seoul' }
// ]
// Apply patches
const updated = applyPatch(document, patches);
// original document unchanged (immutable: true by default)
```
---
## Sub-path Imports
Use sub-path imports to minimize bundle size:
| Sub-path | Exports |
|----------|---------|
| `@winglet/json` | All exports |
| `@winglet/json/pointer` | All JSONPointer utilities |
| `@winglet/json/pointer-manipulator` | `getValue`, `setValue` |
| `@winglet/json/pointer-patch` | `compare`, `applyPatch`, `difference`, `mergePatch` |
| `@winglet/json/pointer-escape` | `escapePath`, `unescapePath`, `escapeSegment` |
| `@winglet/json/pointer-common` | `JSONPointer` constants, `convertJsonPointerToPath` |
| `@winglet/json/path` | `JSONPath` constants |
| `@winglet/json/path-common` | `getJSONPath`, `convertJsonPathToPointer` |
```typescript
import { getValue, setValue } from '@winglet/json/pointer-manipulator';
import { compare, applyPatch } from '@winglet/json/pointer-patch';
import { escapePath, escapeSegment } from '@winglet/json/pointer-escape';
```
---
## JSON Pointer
A JSON Pointer (RFC 6901) is a string that identifies a specific value within a JSON document. Each reference token is prefixed with `/`.
### Pointer Syntax
| Pointer | Points To |
|---------|-----------|
| `""` | The entire document |
| `"/foo"` | Property `foo` at root |
| `"/foo/bar"` | Property `bar` nested under `foo` |
| `"/arr/0"` | First element of array `arr` |
| `"/a~1b"` | Key `a/b` (slash escaped as `~1`) |
| `"/a~0b"` | Key `a~b` (tilde escaped as `~0`) |
### getValue
Reads a value from a JSON document at the location specified by a JSON Pointer.
```typescript
function getValue<Output>(
value: object | any[],
pointer: string | string[],
): Output
```
**Parameters**
| Parameter | Type | Description |
|-----------|------|-------------|
| `value` | `object \| any[]` | Source JSON document (plain object or array) |
| `pointer` | `string \| string[]` | JSON Pointer string or array of reference tokens |
**Returns**: The value at the specified location.
**Throws**: `JSONPointerError` with code `INVALID_INPUT`, `INVALID_POINTER`, or `PROPERTY_NOT_FOUND`.
```typescript
import { getValue } from '@winglet/json/pointer-manipulator';
const doc = {
store: {
books: [
{ title: 'RFC 6901', author: 'IETF', price: 0 },
{ title: 'Clean Code', author: 'Martin', price: 35 },
],
},
};
getValue(doc, '/store/books/0/title'); // 'RFC 6901'
getValue(doc, '/store/books/1/price'); // 35
getValue(doc, ''); // entire document
getValue(doc, ['store', 'books', '0']); // { title: 'RFC 6901', ... }
// Escaped keys
const data = { 'a/b': 'slash', 'a~b': 'tilde' };
getValue(data, '/a~1b'); // 'slash'
getValue(data, '/a~0b'); // 'tilde'
```
### setValue
Sets a value at the location specified by a JSON Pointer. **Mutates the input object in place.**
```typescript
function setValue<Output>(
value: object | any[],
pointer: string | string[],
input: any,
options?: {
overwrite?: boolean; // default: true
preserveNull?: boolean; // default: true
},
): Output
```
**Parameters**
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `value` | `object \| any[]` | — | Target JSON document to modify |
| `pointer` | `string \| string[]` | — | JSON Pointer to the target location |
| `input` | `any` | — | Value to set |
| `options.overwrite` | `boolean` | `true` | Replace existing values |
| `options.preserveNull` | `boolean` | `true` | Preserve `null` intermediate nodes |
**Returns**: The modified input object (same reference).
```typescript
import { setValue } from '@winglet/json/pointer-manipulator';
const obj = { user: {} };
// Basic set
setValue(obj, '/user/name', 'Alice');
// obj.user.name === 'Alice'
// Auto-create intermediate paths
setValue(obj, '/config/db/host', 'localhost');
// obj.config.db.host === 'localhost'
// Append to array
const arr = { items: [1, 2, 3] };
setValue(arr, '/items/-', 4);
// arr.items === [1, 2, 3, 4]
// Conditional set
setValue(obj, '/user/name', 'Bob', { overwrite: false });
// obj.user.name still 'Alice' — not overwritten
// Traverse through null
const nulled = { profile: null };
setValue(nulled, '/profile/name', 'Alice', { preserveNull: false });
// nulled.profile === { name: 'Alice' }
```
### escapePath / unescapePath
Escape and unescape complete JSON Pointer paths. Segment separators (`/`) are preserved; only `~` and `/` within segments are escaped.
```typescript
function escapePath(path: string): string
function unescapePath(path: string): string
```
```typescript
import { escapePath, unescapePath } from '@winglet/json/pointer-escape';
escapePath('/users/john~doe/settings');
// '/users/john~0doe/settings'
unescapePath('/users/john~0doe/settings');
// '/users/john~doe/settings'
// Round-trip
const original = '/data/key~with~tildes/value';
unescapePath(escapePath(original)) === original; // true
```
### escapeSegment
Escapes a single reference token (one path segment), converting both `~` and `/` to their escape sequences.
```typescript
function escapeSegment(segment: string): string
```
```typescript
import { escapeSegment } from '@winglet/json/pointer-escape';
escapeSegment('api/v1'); // 'api~1v1'
escapeSegment('config~prod'); // 'config~0prod'
escapeSegment('normal'); // 'normal' (no escaping needed)
// Building a pointer from a dynamic key
const key = 'api/v1';
const pointer = `/${escapeSegment(key)}/status`;
// '/api~1v1/status'
```
### convertJsonPointerToPath
Converts a JSON Pointer string to an array of unescaped reference tokens.
```typescript
function convertJsonPointerToPath(pointer: string): string[]
```
```typescript
import { convertJsonPointerToPath } from '@winglet/json/pointer-common';
convertJsonPointerToPath('/foo/bar'); // ['foo', 'bar']
convertJsonPointerToPath('/a~1b/c~0d'); // ['a/b', 'c~d']
convertJsonPointerToPath(''); // []
```
---
## JSON Patch
JSON Patch (RFC 6902) describes a sequence of operations to transform a JSON document.
### Patch Operations
| Operation | Fields | Description |
|-----------|--------|-------------|
| `add` | `op`, `path`, `value` | Add value at path |
| `remove` | `op`, `path` | Remove value at path |
| `replace` | `op`, `path`, `value` | Replace value at path |
| `move` | `op`, `path`, `from` | Move value from one path to another |
| `copy` | `op`, `path`, `from` | Copy value from one path to another |
| `test` | `op`, `path`, `value` | Assert that value at path equals given value |
### compare
Generates an array of JSON Patch operations that transform `source` into `target`.
```typescript
function compare<Source, Target>(
source: Source,
target: Target,
options?: {
strict?: boolean; // default: false
immutable?: boolean; // default: true
},
): Patch[]
```
```typescript
import { compare } from '@winglet/json/pointer-patch';
const source = { name: 'Alice', age: 25, role: 'user' };
const target = { name: 'Alice', age: 26, permissions: ['read'] };
compare(source, target);
// [
// { op: 'replace', path: '/age', value: 26 },
// { op: 'remove', path: '/role' },
// { op: 'add', path: '/permissions', value: ['read'] }
// ]
// Identical objects return empty array
compare({ x: 1 }, { x: 1 }); // []
// Nested diff
compare(
{ settings: { theme: 'dark', lang: 'ko' } },
{ settings: { theme: 'light', lang: 'ko' } }
);
// [{ op: 'replace', path: '/settings/theme', value: 'light' }]
```
### applyPatch
Applies an array of JSON Patch operations sequentially to a source document.
```typescript
function applyPatch<Result>(
source: object | any[],
patches: Patch[],
options?: {
strict?: boolean; // default: false
immutable?: boolean; // default: true
protectPrototype?: boolean; // default: true
},
): Result
```
```typescript
import { applyPatch } from '@winglet/json/pointer-patch';
const source = { name: 'Alice', tags: ['admin'] };
const result = applyPatch(source, [
{ op: 'add', path: '/email', value: 'alice@example.com' },
{ op: 'replace', path: '/name', value: 'Alicia' },
{ op: 'add', path: '/tags/-', value: 'editor' },
{ op: 'remove', path: '/tags/0' },
]);
// { name: 'Alicia', email: 'alice@example.com', tags: ['editor'] }
// source unchanged
// Move
applyPatch({ a: { b: 1 } }, [
{ op: 'move', from: '/a/b', path: '/c' },
]);
// { a: {}, c: 1 }
// Test + conditional update
applyPatch({ status: 'draft' }, [
{ op: 'test', path: '/status', value: 'draft' },
{ op: 'replace', path: '/status', value: 'published' },
]);
// { status: 'published' }
```
### difference
Generates a JSON Merge Patch (RFC 7396) representing the differences between two values.
```typescript
function difference(
source: JsonValue,
target: JsonValue,
): JsonValue | undefined
```
Returns `undefined` when source and target are identical. Returns `null`-annotated object for object diffs (where `null` means "remove this property"). Returns the target value directly for arrays and type mismatches.
```typescript
import { difference } from '@winglet/json/pointer-patch';
difference({ a: 1, b: 2 }, { a: 1, b: 3, c: 4 });
// { b: 3, c: 4 }
difference({ a: 1, b: 2 }, { a: 1 });
// { b: null } ← null means "remove b"
difference({ x: 1 }, { x: 1 });
// undefined ← no changes
difference([1, 2, 3], [1, 2, 4]);
// [1, 2, 4] ← arrays replaced wholesale
// Nested
difference(
{ user: { name: 'Alice', role: 'admin', temp: true } },
{ user: { name: 'Bob', role: 'admin' } }
);
// { user: { name: 'Bob', temp: null } }
```
### mergePatch
Applies a JSON Merge Patch document to a source value (RFC 7396).
```typescript
function mergePatch<Type>(
source: JsonValue,
mergePatchBody: JsonValue | undefined,
immutable?: boolean, // default: true
): Type
```
- `null` values in the patch remove the corresponding property.
- Non-object patch (including arrays) replaces the source entirely.
- `undefined` patch returns source unchanged.
```typescript
import { mergePatch } from '@winglet/json/pointer-patch';
// Add and replace
mergePatch({ name: 'Alice', age: 25 }, { age: 26, city: 'Seoul' });
// { name: 'Alice', age: 26, city: 'Seoul' }
// Remove with null
mergePatch({ name: 'Alice', temp: 'data' }, { temp: null });
// { name: 'Alice' }
// Nested
mergePatch(
{ user: { name: 'Alice', role: 'admin' } },
{ user: { role: null, email: 'alice@example.com' } }
);
// { user: { name: 'Alice', email: 'alice@example.com' } }
// Non-object patch = full replacement
mergePatch({ complex: true }, 'simple');
// 'simple'
// Mutable mode (better performance)
const src = { a: 1 };
const res = mergePatch(src, { b: 2 }, false);
src === res; // true — same reference
```
---
## JSON Path
### JSONPath Constants
```typescript
import { JSONPath } from '@winglet/json/path';
JSONPath.Root; // '$' — root of the document
JSONPath.Current; // '@' — current node in expressions
JSONPath.Child; // '.' — child property accessor
JSONPath.Filter; // '#' — filter operator
```
### getJSONPath
Finds the JSONPath expression from `root` to `target` using depth-first search (reference equality).
```typescript
function getJSONPath<Root extends object, Target>(
root: Root,
target: Target,
): string | null
```
Returns `null` when `target` is not reachable from `root` or is a primitive at a leaf node.
```typescript
import { getJSONPath } from '@winglet/json/path-common';
const doc = { a: { b: [{ c: 'value' }] } };
getJSONPath(doc, doc); // '$'
getJSONPath(doc, doc.a); // '$.a'
getJSONPath(doc, doc.a.b); // '$.a.b'
getJSONPath(doc, doc.a.b[0]); // '$.a.b[0]'
getJSONPath(doc, {}); // null — different reference
// Keys with dots use bracket notation
const special = { 'key.with.dots': { nested: true } };
getJSONPath(special, special['key.with.dots']);
// "$['key.with.dots']"
```
### convertJsonPathToPointer
Converts a JSONPath string to an equivalent JSON Pointer string.
```typescript
function convertJsonPathToPointer(jsonPath: string): string
```
```typescript
import { convertJsonPathToPointer } from '@winglet/json/path-common';
convertJsonPathToPointer('$.foo.bar'); // '/foo/bar'
convertJsonPathToPointer('$.users[0].name'); // '/users/0/name'
convertJsonPathToPointer('$'); // ''
convertJsonPathToPointer("$['a/b'].c"); // '/a~1b/c'
```
---
## Type Definitions
```typescript
// Primitive JSON types
type JsonPrimitive = string | number | boolean | null;
type JsonArray = Array<any>;
type JsonObject = Record<string, any>;
type JsonValue = JsonPrimitive | JsonArray | JsonObject;
type JsonRoot = JsonArray | JsonObject;
// Patch operation types
type Operation = 'add' | 'replace' | 'remove' | 'move' | 'copy' | 'test';
interface AddPatch<V> { op: 'add'; path: string; value: V }
interface ReplacePatch<V> { op: 'replace'; path: string; value: V }
interface RemovePatch { op: 'remove'; path: string }
interface MovePatch { op: 'move'; path: string; from: string }
interface CopyPatch { op: 'copy'; path: string; from: string }
interface TestPatch<V> { op: 'test'; path: string; value: V }
type Patch = AddPatch<any> | ReplacePatch<any> | RemovePatch
| MovePatch | CopyPatch | TestPatch<any>;
// Options
type CompareOptions = { strict?: boolean; immutable?: boolean };
type ApplyPatchOptions = { strict?: boolean; immutable?: boolean; protectPrototype?: boolean };
```
---
## Security
### Prototype Pollution Protection
`applyPatch` rejects patches targeting `__proto__`, `constructor`, or `prototype` paths when `protectPrototype: true` (the default).
```typescript
// This throws — prototype pollution blocked
applyPatch({}, [{ op: 'add', path: '/__proto__/isAdmin', value: true }]);
// Explicit opt-out (trusted sources only)
applyPatch(trustedSource, trustedPatches, { protectPrototype: false });
```
### Input Validation
`getValue` and `setValue` reject non-plain-object inputs:
```typescript
getValue('string', '/path'); // throws INVALID_INPUT
getValue(null, '/path'); // throws INVALID_INPUT
getValue(new Map(), '/path'); // throws INVALID_INPUT
```
---
## Error Handling
```typescript
import { JSONPointerError, isJSONPointerError } from '@winglet/json';
class JSONPointerError extends Error {
code: 'INVALID_INPUT' | 'INVALID_POINTER' | 'PROPERTY_NOT_FOUND';
details: Record<string, unknown>;
}
```
| Code | Trigger |
|------|---------|
| `INVALID_INPUT` | Input is not a plain object or array |
| `INVALID_POINTER` | Pointer syntax is malformed |
| `PROPERTY_NOT_FOUND` | Path does not exist in the document |
```typescript
import { getValue, JSONPointerError } from '@winglet/json';
try {
getValue({}, '/missing');
} catch (e) {
if (e instanceof JSONPointerError) {
console.log(e.code); // 'PROPERTY_NOT_FOUND'
console.log(e.details); // { pointer: '/missing', ... }
}
}
```
---
## Performance
| Scenario | Recommendation |
|----------|---------------|
| Large deeply-nested documents | `immutable: false` to avoid deep clone overhead |
| Sequential patch application | `strict: false` (default) — skips extra validation per operation |
| Trusted patch sources | `protectPrototype: false` to remove prototype checks |
| Memory-sensitive environments | `immutable: false` in `mergePatch` to avoid cloning |
```typescript
// Maximum performance (trusted environment only)
applyPatch(source, patches, {
immutable: false,
strict: false,
protectPrototype: false,
});
```
---
## Related Standards
- [RFC 6901 — JSON Pointer](https://datatracker.ietf.org/doc/html/rfc6901)
- [RFC 6902 — JSON Patch](https://datatracker.ietf.org/doc/html/rfc6902)
- [RFC 7396 — JSON Merge Patch](https://datatracker.ietf.org/doc/html/rfc7396)
- [JSONPath — XPath for JSON](https://goessner.net/articles/JsonPath/)