diff --git a/.changeset/add-path-helpers.md b/.changeset/add-path-helpers.md new file mode 100644 index 0000000..f0c1938 --- /dev/null +++ b/.changeset/add-path-helpers.md @@ -0,0 +1,11 @@ +--- +'flattify': minor +--- + +Add the `flattify/path` entry to read and write nested values by path, with autocompleted paths and typed values: + +- `get(obj, 'items.0.sku')`: the result is typed from `obj` (`string | undefined`) +- `set(obj, 'db.port', 6543)`: the value is checked against the path, and missing objects and arrays are created +- `has`, `del`, `paths` (the paths `flatten` produces), `parsePath`, `stringifyPath` and `escapeKey` +- `Path`, `Get` and `SetValue` types +- Every helper takes the `delimiter`, `notation` and `escape` options, and is safe against prototype pollution diff --git a/docs/file-docs.md b/docs/file-docs.md index ca30430..16d083a 100644 --- a/docs/file-docs.md +++ b/docs/file-docs.md @@ -55,3 +55,8 @@ project stays on Miniflare 4 because Miniflare 5 is still an alpha with a differ ### test/typescript-5.0 Compiles a consumer file against the built `.d.ts` with TypeScript 5.0 (`pnpm run test:ts-legacy`), the oldest version supported: the public types rely on `const` type parameters, added in 5.0. + +### path/package.json +Makes `import ... from 'flattify/path'` resolve with `moduleResolution: "node"` (node10) and older +bundlers, which ignore the `exports` field of `package.json`. They look for a `path` folder in the +package and follow its `main`, `module` and `types` fields to `dist/`. Checked by `attw` in `lint:package`. diff --git a/jsr.json b/jsr.json index adb5968..53424ed 100644 --- a/jsr.json +++ b/jsr.json @@ -3,7 +3,10 @@ "name": "@nandomb/flattify", "version": "1.0.2", "license": "MIT", - "exports": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./path": "./src/path/index.ts" + }, "publish": { "include": [ "src/**/*.ts", diff --git a/package.json b/package.json index a1dc409..881cd2f 100644 --- a/package.json +++ b/package.json @@ -33,10 +33,21 @@ "default": "./dist/index.cjs" } }, + "./path": { + "import": { + "types": "./dist/path.d.ts", + "default": "./dist/path.js" + }, + "require": { + "types": "./dist/path.d.cts", + "default": "./dist/path.cjs" + } + }, "./package.json": "./package.json" }, "files": [ - "dist" + "dist", + "path" ], "scripts": { "dev": "vitest --project node", diff --git a/path/package.json b/path/package.json new file mode 100644 index 0000000..d235681 --- /dev/null +++ b/path/package.json @@ -0,0 +1,6 @@ +{ + "private": true, + "main": "../dist/path.cjs", + "module": "../dist/path.js", + "types": "../dist/path.d.cts" +} diff --git a/src/flatten/types.ts b/src/flatten/types.ts index 284b11d..3406905 100644 --- a/src/flatten/types.ts +++ b/src/flatten/types.ts @@ -23,9 +23,9 @@ export interface FlattenOptions { } /** Nesting levels TypeScript expands before typing deeper paths as `unknown`. */ -type TypeDepthLimit = 10; +export type TypeDepthLimit = 10; -interface Context { +export interface Context { delimiter: string; notation: Notation; maxDepth: number | undefined; @@ -34,10 +34,10 @@ interface Context { escape: boolean; } -type Kind = V extends Leaf ? 'leaf' : V extends readonly unknown[] ? (C['safe'] extends true ? 'leaf' : 'array') : V extends object ? ([keyof V] extends [never] ? 'unknown' : 'object') : 'leaf'; +export type Kind = V extends Leaf ? 'leaf' : V extends readonly unknown[] ? (C['safe'] extends true ? 'leaf' : 'array') : V extends object ? ([keyof V] extends [never] ? 'unknown' : 'object') : 'leaf'; type LeavesOf = V extends unknown ? (Kind extends 'leaf' ? V : never) : never; -type ContainersOf = V extends unknown ? (Kind extends 'leaf' ? never : V) : never; +export type ContainersOf = V extends unknown ? (Kind extends 'leaf' ? never : V) : never; /** Mirrors `escapeKey`: see `src/shared/path.ts`. `X` is `'['` in bracket notation. */ type EscapeKey = NeedsEscape extends true @@ -50,7 +50,7 @@ type NeedsEscape = K exten type Overlaps = Prefixes extends infer P ? (P extends string ? (`${P}${D}` extends `${D}${string}` ? P : never) : never) : never; type Prefixes = D extends `${infer Char}${infer Rest}` ? (Rest extends '' ? never : `${Acc}${Char}` | Prefixes) : never; /** Mirrors `pathFormat().join`: appends key `K` to path `P` (the top level is at depth `[]`). */ -type Join

= C['notation'] extends 'pointer' +export type Join

= C['notation'] extends 'pointer' ? `${P}/${K extends string ? ReplaceAll, '/', '~1'> : K}` : C['notation'] extends 'bracket' ? IsIndex extends true @@ -60,7 +60,7 @@ type Join

= D extends [] ? K : `${P}${C['delimiter']}${K}`; /** Any path below `K`, for values whose shape is unknown. */ -type Below = C['notation'] extends 'pointer' ? `${K}/${string}` : C['notation'] extends 'bracket' ? `${K}${C['delimiter']}${string}` | `${K}[${string}` : `${K}${C['delimiter']}${string}`; +export type Below = C['notation'] extends 'pointer' ? `${K}/${string}` : C['notation'] extends 'bracket' ? `${K}${C['delimiter']}${string}` | `${K}[${string}` : `${K}${C['delimiter']}${string}`; /** `?` is skipped for template keys (`` `tags.${number}.name` ``): they are index signatures, where it would only add `undefined`. */ type Field = [V] extends [never] ? {} : Optional extends true ? ({} extends Record ? { [_ in K]: V } : { [_ in K]?: V }) : { [_ in K]: V }; @@ -112,7 +112,7 @@ type ObjectEntries = string extends K ? false : number extends K ? false : {} extends Pick ? true : false; -type TupleEntries = { [K in keyof T as K extends `${number}` ? K : never]: T[K] }; +export type TupleEntries = { [K in keyof T as K extends `${number}` ? K : never]: T[K] }; type FlatContainer = T extends readonly unknown[] ? number extends T['length'] diff --git a/src/index.ts b/src/index.ts index ba16708..2fd23f6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,4 +17,5 @@ export * from './flatten/index.ts'; export * from './unflatten/index.ts'; +export type { Notation } from './shared/notation.ts'; export type { Prettify } from './shared/types.ts'; diff --git a/src/path/index.ts b/src/path/index.ts new file mode 100644 index 0000000..d1d346e --- /dev/null +++ b/src/path/index.ts @@ -0,0 +1,18 @@ +/** + * Read and write nested values by path, with autocompleted paths and typed values. + * + * @example + * ```ts + * import { get, set } from 'flattify/path'; + * + * const config = { db: { host: 'localhost', port: 5432 } }; + * get(config, 'db.port'); // 5432, typed number + * set(config, 'db.host', 'db.internal'); + * ``` + * + * @module + */ + +export { del, escapeKey, get, has, parsePath, paths, set, stringifyPath } from './path.ts'; +export type { Get, Path, PathOptions, SetOptions, SetValue } from './types.ts'; +export type { Notation } from '../shared/notation.ts'; diff --git a/src/path/path.spec.ts b/src/path/path.spec.ts new file mode 100644 index 0000000..0b62086 --- /dev/null +++ b/src/path/path.spec.ts @@ -0,0 +1,271 @@ +import { afterEach, describe, expect, expectTypeOf, test } from 'vitest'; +import { flatten } from '../index.ts'; +import { del, escapeKey, get, has, parsePath, paths, set, stringifyPath, type Get, type Path, type SetValue } from './index.ts'; + +interface Order { + id: number; + items: { sku: string; qty: number }[]; + customer?: { name: string }; + meta: Record; + point: [number, string]; + at: Date; +} + +const order = (): Order => ({ id: 1, items: [{ sku: 'A1', qty: 2 }], customer: { name: 'Ada' }, meta: { source: 'web' }, point: [1, 'x'], at: new Date(0) }); + +afterEach(() => { + delete (Object.prototype as Record).polluted; +}); + +describe('get', () => { + test('Should read nested values', () => { + const o = order(); + expect(get(o, 'items.0.sku')).toBe('A1'); + expect(get(o, 'customer.name')).toBe('Ada'); + expect(get(o, 'items')).toBe(o.items); + expect(get(o, 'point.1')).toBe('x'); + }); + + test('Should return undefined for missing keys', () => { + const o = order(); + expect(get(o, 'items.5.sku')).toBeUndefined(); + expect(get({ a: null } as { a: { b: number } | null }, 'a.b')).toBeUndefined(); + expect(get(o, 'id.toFixed' as string)).toBeUndefined(); + }); + + test('Should read own keys only on plain objects and arrays', () => { + expect(get({}, 'constructor' as string)).toBeUndefined(); + expect(get({}, 'toString' as string)).toBeUndefined(); + expect(get({ a: [] }, 'a.length' as string)).toBe(0); + expect(get({ constructor: 'x' }, 'constructor')).toBe('x'); + }); + + test('Should read inherited properties of other objects, except the prototype chain keys', () => { + class User { + constructor(public first: string) {} + get display(): string { + return this.first.toUpperCase(); + } + } + const data = { user: new User('ada') }; + expect(get(data, 'user.display' as string)).toBe('ADA'); + expect(get(data, 'user.constructor' as string)).toBeUndefined(); + expect(get(data, 'user.__proto__' as string)).toBeUndefined(); + expect(get(data, 'user.missing' as string)).toBeUndefined(); + }); + + test('Should follow the notation and the escapes', () => { + const o = order(); + expect(get(o, '/items/0/qty', { notation: 'pointer' })).toBe(2); + expect(get(o, 'items[0].qty', { notation: 'bracket' })).toBe(2); + expect(get({ 'a.b': { c: 1 } }, 'a\\.b.c')).toBe(1); + expect(get({ a: { b: 1 } }, 'a_b', { delimiter: '_' })).toBe(1); + }); + + test('Should throw on a non-object input or invalid options', () => { + expect(() => get(null as unknown as object, 'a')).toThrow(new TypeError('get() expects an object or an array')); + expect(() => get({}, 'a', { notation: 'x' as 'dot' })).toThrow(TypeError); + expect(() => get({}, 'a', { delimiter: '' })).toThrow(TypeError); + }); +}); + +describe('has', () => { + test('Should tell whether every key of the path exists', () => { + expect(has({ a: { b: undefined } }, 'a.b')).toBe(true); + expect(has({ a: {} }, 'a.b')).toBe(false); + expect(has({ a: 1 }, 'a.b')).toBe(false); + expect(has({ list: ['x'] }, 'list.0')).toBe(true); + expect(has({}, 'toString')).toBe(false); + expect(has({ a: { b: 1 } }, '/a/b', { notation: 'pointer' })).toBe(true); + }); + + test('Should throw on a non-object input', () => { + expect(() => has(1 as unknown as object, 'a')).toThrow(new TypeError('has() expects an object or an array')); + }); +}); + +describe('set', () => { + test('Should set nested values and return the same object', () => { + const o = order(); + expect(set(o, 'items.0.qty', 5)).toBe(o); + expect(o.items[0].qty).toBe(5); + }); + + test('Should create missing objects and arrays', () => { + const target: Record = {}; + set(target, 'a.b.0.c', 1); + expect(target).toEqual({ a: { b: [{ c: 1 }] } }); + set(target, 'x.0', 1, { object: true }); + expect(target.x).toEqual({ 0: 1 }); + set(target, 'y.5000', 1); + expect(target.y).toEqual({ 5000: 1 }); + set(target, 'z.3', 1, { arrayLimit: 2 }); + expect(target.z).toEqual({ 3: 1 }); + }); + + test('Should replace values that are in the way', () => { + const target: Record = { a: 1 }; + set(target, 'a.b', 2); + expect(target).toEqual({ a: { b: 2 } }); + }); + + test('Should write into class instances along the path', () => { + class Box { + content = { size: 1 }; + } + const target = { box: new Box() }; + set(target, 'box.content.size', 2); + expect(target.box).toBeInstanceOf(Box); + expect(target.box.content.size).toBe(2); + }); + + test('Should follow the notation', () => { + const target: Record = {}; + set(target, '/a~1b/0', 'x', { notation: 'pointer' }); + set(target, 'c[0].d', 'y', { notation: 'bracket' }); + expect(target).toEqual({ 'a/b': ['x'], c: [{ d: 'y' }] }); + }); + + test.each(['__proto__.polluted', 'a.__proto__.polluted', 'constructor.prototype.polluted', '__proto__'])('Should not pollute Object.prototype through %j', (path) => { + const target: Record = {}; + set(target, path, true); + expect(({} as Record).polluted).toBeUndefined(); + expect(Object.getPrototypeOf(target)).toBe(Object.prototype); + }); + + test('Should create constructor and prototype as own data keys', () => { + const target: Record = {}; + set(target, 'constructor.prototype.x', 1); + expect(target).toEqual({ constructor: { prototype: { x: 1 } } }); + expect(Object.prototype.constructor).toBe(Object); + }); + + test('Should throw on a non-object input or an invalid arrayLimit', () => { + expect(() => set('x' as unknown as Record, 'a', 1)).toThrow(new TypeError('set() expects an object or an array')); + expect(() => set({} as Record, 'a', 1, { arrayLimit: -1 })).toThrow(new RangeError('`arrayLimit` must be a non-negative integer or Infinity')); + expect(() => set({} as Record, 'a', 1, { arrayLimit: Infinity })).not.toThrow(); + }); +}); + +describe('del', () => { + test('Should delete the key and tell whether it existed', () => { + const user = { name: 'Ada', auth: { password: 'secret' } }; + expect(del(user, 'auth.password')).toBe(true); + expect(user).toEqual({ name: 'Ada', auth: {} }); + expect(del(user, 'auth.password')).toBe(false); + expect(del(user, 'missing.key')).toBe(false); + expect(del(user, 'name.length')).toBe(false); + expect(del(user, 'name.x.y')).toBe(false); + }); + + test('Should leave a hole in arrays', () => { + const target = { list: ['a', 'b'] }; + expect(del(target, 'list.0')).toBe(true); + expect(0 in target.list).toBe(false); + expect(target.list).toHaveLength(2); + }); + + test('Should not delete inherited keys', () => { + expect(del({}, 'toString')).toBe(false); + expect(Object.prototype.toString).toBeTypeOf('function'); + }); + + test('Should throw on a non-object input', () => { + expect(() => del(undefined as unknown as object, 'a')).toThrow(new TypeError('del() expects an object or an array')); + }); +}); + +describe('paths', () => { + test('Should list the paths flatten produces', () => { + const o = order(); + expect(paths(o)).toEqual(Object.keys(flatten(o))); + expect(paths({ a: { b: [1] } }, { notation: 'pointer' })).toEqual(['/a/b/0']); + }); +}); + +describe('parsePath and stringifyPath', () => { + test.each([ + ['user.tags.0', {}, ['user', 'tags', '0']], + ['a\\.b.c', {}, ['a.b', 'c']], + ['items[0][id]', { notation: 'bracket' }, ['items', '0', 'id']], + ['/a~1b/c', { notation: 'pointer' }, ['a/b', 'c']], + ] as const)('Should parse %j', (path, options, expected) => { + expect(parsePath(path, options)).toEqual(expected); + }); + + test.each([ + [['user', 'first.name'], {}, 'user.first\\.name'], + [['items', 0, 'id'], { notation: 'bracket' }, 'items[0].id'], + [['a/b', 'c'], { notation: 'pointer' }, '/a~1b/c'], + [[], {}, ''], + ] as const)('Should stringify %j', (keys, options, expected) => { + expect(stringifyPath(keys, options)).toBe(expected); + if (keys.length > 0) expect(parsePath(expected, options)).toEqual(keys.map(String)); + }); +}); + +test('Should use the default options when none are given', () => { + expect(stringifyPath(['a.b', 'c'])).toBe('a\\.b.c'); + expect(escapeKey('a.b')).toBe('a\\.b'); +}); + +describe('escapeKey', () => { + test.each([ + ['first.name', {}, 'first\\.name'], + ['a[0]', { notation: 'bracket' }, 'a\\[0]'], + ['a/b~c', { notation: 'pointer' }, 'a~1b~0c'], + ['a_b', { delimiter: '_' }, 'a\\_b'], + ] as const)('Should escape %j', (key, options, expected) => { + expect(escapeKey(key, options)).toBe(expected); + }); +}); + +describe('path types', () => { + test('Path: Should list every path, to containers and values', () => { + type Expected = 'id' | 'items' | `items.${number}` | `items.${number}.sku` | `items.${number}.qty` | 'customer' | 'customer.name' | 'meta' | `meta.${string}` | 'point' | 'point.0' | 'point.1' | 'at'; + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf<'a' | `a[${number}]` | `a[${number}].b`>(); + expectTypeOf>().toEqualTypeOf<'/a' | '/a/b'>(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test('Get: Should type the value at the path', () => { + const o = order(); + expectTypeOf(get(o, 'id')).toEqualTypeOf(); + expectTypeOf(get(o, 'items.0.sku')).toEqualTypeOf(); + expectTypeOf(get(o, 'customer.name')).toEqualTypeOf(); + expectTypeOf(get(o, 'meta.source')).toEqualTypeOf(); + expectTypeOf(get(o, 'point.1')).toEqualTypeOf(); + expectTypeOf(get(o, 'at')).toEqualTypeOf(); + expectTypeOf(get(o, '/items/0/qty', { notation: 'pointer' })).toEqualTypeOf(); + expectTypeOf(get(o, 'dynamic' as string)).toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf<'x'>(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf, 'a.b.c'>>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test('set: Should check the value against the path', () => { + const o = order(); + set(o, 'items.0.qty', 3); + // @ts-expect-error qty is a number + expect(() => set(o, 'items.0.qty', '3')).not.toThrow(); + // @ts-expect-error there is no price + expect(() => set(o, 'items.0.price', 3)).not.toThrow(); + expectTypeOf>().toEqualTypeOf<{ sku: string; qty: number }>(); + expectTypeOf>().toEqualTypeOf(); + }); + + test('parsePath: Should type the keys', () => { + expectTypeOf(parsePath('a.b\\.c')).toEqualTypeOf<['a', 'b.c']>(); + expectTypeOf(parsePath('items[0][id]', { notation: 'bracket' })).toEqualTypeOf<['items', '0', 'id']>(); + expectTypeOf(parsePath('a' as string)).toEqualTypeOf(); + }); + + test('paths: Should type the paths', () => { + expectTypeOf(paths({ a: { b: 1 } })).toEqualTypeOf<'a.b'[]>(); + }); +}); diff --git a/src/path/path.ts b/src/path/path.ts new file mode 100644 index 0000000..6b0a161 --- /dev/null +++ b/src/path/path.ts @@ -0,0 +1,221 @@ +import { flatten } from '../flatten/flatten.ts'; +import type { Flatten, FlattenOptions } from '../flatten/types.ts'; +import { assign, isPlainObject } from '../shared/guards.ts'; +import { pathFormat, validateNotation, type PathFormat } from '../shared/notation.ts'; +import { escapeKey as escapeDotKey, escapePointer, isIndex } from '../shared/path.ts'; +import type { PathContext, Segments } from '../shared/path-types.ts'; +import type { Option } from '../shared/types.ts'; +import { validate } from '../shared/validate.ts'; +import type { Get, Path, PathOptions, SetOptions, SetValue } from './types.ts'; + +type Target = Record; + +const MISSING: unique symbol = Symbol('missing'); +const FORBIDDEN = new Set(['__proto__', 'constructor', 'prototype']); + +const hasOwn = (target: object, key: string): boolean => Object.prototype.hasOwnProperty.call(target, key); +const isTraversable = (value: unknown): value is Target => (typeof value === 'object' && value !== null) || typeof value === 'function'; + +function formatOf(options: PathOptions): PathFormat { + const { delimiter = '.', notation = 'dot', escape = true } = options; + validateNotation(notation); + if (notation !== 'pointer') validate(delimiter, escape); + return pathFormat(notation, delimiter, escape); +} + +/** + * Reads `key` of `target`. Plain objects and arrays expose their own keys only, so `'toString'` or + * `'constructor'` are missing on data; other objects (class instances, dates...) expose inherited + * properties too, except `__proto__`, `constructor` and `prototype`. + */ +function read(target: Target, key: string): unknown { + if (isPlainObject(target) || Array.isArray(target)) return hasOwn(target, key) ? target[key] : MISSING; + if (FORBIDDEN.has(key) && !hasOwn(target, key)) return MISSING; + return key in target ? target[key] : MISSING; +} + +function checkTarget(input: unknown, name: string): void { + if (!isTraversable(input)) throw new TypeError(`${name}() expects an object or an array`); +} + +/** + * Reads the value at `path`, or `undefined` when a key is missing along the way. + * + * The path is autocompleted and the result typed from `obj`. Plain objects and arrays are read through + * their own keys only: `get({}, 'constructor')` is `undefined`. + * + * @example + * ```ts + * import { get } from 'flattify/path'; + * + * const order = { items: [{ sku: 'A1', qty: 2 }] }; + * get(order, 'items.0.sku'); // 'A1', typed string | undefined + * get(order, '/items/0/qty', { notation: 'pointer' }); // 2 + * ``` + */ +export function get>(obj: T, path: P): Get; +export function get, const O extends PathOptions>(obj: T, path: P, options: O): Get; +export function get(obj: object, path: string, options?: PathOptions): unknown; +export function get(obj: object, path: string, options: PathOptions = {}): unknown { + checkTarget(obj, 'get'); + let current: unknown = obj; + for (const key of formatOf(options).split(path)) { + if (!isTraversable(current)) return undefined; + current = read(current, key); + if (current === MISSING) return undefined; + } + return current; +} + +/** + * `true` when every key of `path` exists, even if the value there is `undefined`. + * + * @example + * ```ts + * has({ a: { b: undefined } }, 'a.b'); // true + * has({ a: {} }, 'a.b'); // false + * ``` + */ +export function has(obj: T, path: Path | (string & {}), options?: O): boolean; +export function has(obj: object, path: string, options: PathOptions = {}): boolean { + checkTarget(obj, 'has'); + let current: unknown = obj; + for (const key of formatOf(options).split(path)) { + if (!isTraversable(current)) return false; + current = read(current, key); + if (current === MISSING) return false; + } + return true; +} + +/** + * Sets the value at `path`, creating the missing objects (or arrays, for index keys) along the way, and + * returns `obj`, which is modified. The value is type-checked against the path. + * + * Paths with a `__proto__` segment are ignored; other keys are only ever created as own properties, so + * no path reaches `Object.prototype`. + * + * @example + * ```ts + * import { set } from 'flattify/path'; + * + * const config: { db: { port: number } } = { db: { port: 5432 } }; + * set(config, 'db.port', 6543); + * set(config, 'db.port', '6543'); // type error: string is not number + * ``` + */ +export function set>(obj: T, path: P, value: SetValue): T; +export function set, const O extends SetOptions>(obj: T, path: P, value: SetValue, options: O): T; +export function set(obj: object, path: string, value: unknown, options: SetOptions = {}): object { + checkTarget(obj, 'set'); + const { object = false, arrayLimit = 1000 } = options; + if (arrayLimit !== Infinity && (!Number.isInteger(arrayLimit) || arrayLimit < 0)) throw new RangeError('`arrayLimit` must be a non-negative integer or Infinity'); + const segments = formatOf(options).split(path); + if (segments.includes('__proto__')) return obj; + + let target = obj as Target; + for (let i = 0; i < segments.length - 1; i++) { + const child = read(target, segments[i]); + if (isTraversable(child)) { + target = child; + } else { + const next: Target = !object && isIndex(segments[i + 1], arrayLimit) ? ([] as unknown as Target) : {}; + assign(target, segments[i], next); + target = next; + } + } + assign(target, segments[segments.length - 1], value); + return obj; +} + +/** + * Deletes the key at `path` and returns `true`, or `false` when there was nothing to delete. Array + * elements are deleted in place, leaving a hole, as `delete` does. + * + * @example + * ```ts + * const user = { name: 'Ada', password: 'secret' }; + * del(user, 'password'); // true, user is { name: 'Ada' } + * ``` + */ +export function del(obj: T, path: Path | (string & {}), options?: O): boolean; +export function del(obj: object, path: string, options: PathOptions = {}): boolean { + checkTarget(obj, 'del'); + const segments = formatOf(options).split(path); + let target: unknown = obj; + for (let i = 0; i < segments.length - 1; i++) { + if (!isTraversable(target)) return false; + target = read(target, segments[i]); + } + const last = segments[segments.length - 1]; + if (!isTraversable(target) || !hasOwn(target, last)) return false; + return delete target[last]; +} + +/** + * The path of every value `flatten` would produce, in the same order. + * + * @example + * ```ts + * paths({ user: { name: 'Ada', tags: ['admin'] } }); + * // ['user.name', 'user.tags.0'] + * ``` + */ +export function paths(obj: T): Extract, string>[]; +export function paths(obj: T, options: O): Extract, string>[]; +export function paths(obj: object, options: FlattenOptions = {}): string[] { + return Object.keys(flatten(obj, options)); +} + +type ContextOf = { delimiter: Option; notation: Option; escape: Option }; +type ParsedPath

= string extends P ? string[] : ContextOf extends infer C extends PathContext ? (string extends C['delimiter'] ? string[] : Segments) : string[]; + +/** + * Splits a path into its keys, reading escapes and brackets. + * + * @example + * ```ts + * parsePath('user.tags.0'); // ['user', 'tags', '0'] + * parsePath('a\\.b.c'); // ['a.b', 'c'] + * parsePath('items[0][id]', { notation: 'bracket' }); // ['items', '0', 'id'] + * ``` + */ +export function parsePath(path: P, options?: O): ParsedPath; +export function parsePath(path: string, options: PathOptions = {}): string[] { + return formatOf(options).split(path); +} + +/** + * Joins keys into a path, escaping them when needed: the reverse of `parsePath`. In bracket notation, + * keys that are array indices are written between brackets. + * + * @example + * ```ts + * stringifyPath(['user', 'first.name']); // 'user.first\\.name' + * stringifyPath(['items', '0', 'id'], { notation: 'bracket' }); // 'items[0].id' + * stringifyPath(['a/b', 'c'], { notation: 'pointer' }); // '/a~1b/c' + * ``` + */ +export function stringifyPath(keys: readonly (string | number)[], options: PathOptions = {}): string { + const format = formatOf(options); + const bracket = options.notation === 'bracket'; + let path: string | undefined; + for (const key of keys) path = format.join(path, String(key), bracket && isIndex(String(key), Infinity)); + return path ?? ''; +} + +/** + * Escapes a single key so it can be put in a path as it is. + * + * @example + * ```ts + * `user.${escapeKey('first.name')}`; // 'user.first\\.name' + * escapeKey('a/b', { notation: 'pointer' }); // 'a~1b' + * ``` + */ +export function escapeKey(key: string, options: PathOptions = {}): string { + const { delimiter = '.', notation = 'dot' } = options; + formatOf(options); + if (notation === 'pointer') return escapePointer(key); + return escapeDotKey(key, delimiter, notation === 'bracket' ? '[' : ''); +} diff --git a/src/path/types.ts b/src/path/types.ts new file mode 100644 index 0000000..b7bad11 --- /dev/null +++ b/src/path/types.ts @@ -0,0 +1,107 @@ +import type { Below, ContainersOf, Context, Join, Kind, TupleEntries, TypeDepthLimit } from '../flatten/types.ts'; +import type { Notation } from '../shared/notation.ts'; +import type { Segments } from '../shared/path-types.ts'; +import type { IsAny, IsUnion, Option } from '../shared/types.ts'; + +export interface PathOptions { + /** Separates the keys of a path. Default: `'.'`. */ + delimiter?: string; + /** `'dot'` (`items.0.id`), `'bracket'` (`items[0].id`) or `'pointer'` (`/items/0/id`, RFC 6901). Default: `'dot'`. */ + notation?: Notation; + /** Reads `\` as an escape, so `'a\\.b'` is the single key `'a.b'`. Default: `true`. */ + escape?: boolean; +} + +export interface SetOptions extends PathOptions { + /** Creates objects only, never arrays, for missing keys along the path. Default: `false`. */ + object?: boolean; + /** Largest index that creates an array for a missing key; larger ones create an object. Default: `1000`. */ + arrayLimit?: number; +} + +type ContextOf = { + delimiter: Option; + notation: Option; + escape: Option; + maxDepth: undefined; + safe: false; + keepEmpty: true; +}; + +type IsWide = string extends C['delimiter'] ? true : IsUnion extends true ? true : boolean extends C['escape'] ? true : false; + +type PathsOf = T extends readonly unknown[] + ? number extends T['length'] + ? Node, D, C> + : Entries, P, true, D, C> + : Entries; + +type Entries = { [K in keyof T]-?: K extends string | number ? Node, D, C> : never }[keyof T]; + +type Node = + IsAny extends true ? K | Below : K | (D['length'] extends TypeDepthLimit ? Below : Nested, K, D, C>); + +type Nested = V extends unknown ? (Kind extends 'unknown' ? Below : [PathsOf] extends [infer Paths] ? Paths : never) : never; + +/** + * Every path of `T`, to an object, an array or a value: what `get`, `set`, `has` and `del` accept. + * + * @example + * ```ts + * type P = Path<{ user: { tags: string[] } }>; + * // 'user' | 'user.tags' | `user.tags.${number}` + * ``` + */ +export type Path = IsAny extends true ? string : IsWide> extends true ? string : T extends unknown ? ([PathsOf>] extends [infer Paths extends string] ? Paths : never) : never; + +/** One step of `Walk`. `Get` is `true` when reading, where arrays and records may miss the key. */ +type Step = T extends unknown + ? IsAny extends true + ? any + : unknown extends T + ? unknown + : T extends null | undefined + ? undefined + : T extends readonly unknown[] + ? number extends T['length'] + ? K extends `${number}` ? T[number] | (Get extends true ? undefined : never) : undefined + : K extends keyof T ? T[K] : undefined + : [keyof T] extends [never] + ? unknown + : K extends keyof T + ? T[K] | (string extends keyof T ? (Get extends true ? undefined : never) : never) + : K extends `${infer N extends number}` + ? N extends keyof T ? T[N] : IndexValue + : IndexValue + : never; + +type IndexValue = string extends keyof T ? T[string & keyof T] | (Get extends true ? undefined : never) : undefined; + +type Walk = S extends [infer K extends string, ...infer Rest] ? Walk, Rest, Get> : S extends [] ? T : unknown; + +/** + * The value at path `P` of `T`. Array elements and record values may be missing, so they include + * `undefined`, as with `noUncheckedIndexedAccess`. + * + * @example + * ```ts + * type City = Get<{ user: { address: { city: string } } }, 'user.address.city'>; // string + * type Tag = Get<{ tags: string[] }, 'tags.0'>; // string | undefined + * ``` + */ +export type Get = IsAny extends true + ? any + : string extends P + ? unknown + : IsWide> extends true + ? unknown + : [Segments>] extends [infer S] ? Walk : never; + +/** The type `set` accepts at path `P` of `T`. */ +export type SetValue = IsAny extends true + ? any + : string extends P + ? unknown + : IsWide> extends true + ? unknown + : [Segments>] extends [infer S] ? Walk : never; diff --git a/src/shared/path-types.ts b/src/shared/path-types.ts new file mode 100644 index 0000000..de25d4d --- /dev/null +++ b/src/shared/path-types.ts @@ -0,0 +1,50 @@ +import type { Notation } from './notation.ts'; +import type { ReplaceAll } from './types.ts'; + +/** The options that decide how a path is written. */ +export interface PathContext { + delimiter: string; + notation: Notation; + escape: boolean; +} + +/** Walks a key that contains `\` one character at a time, mirroring `splitPath`. */ +type SplitEscaped = K extends `\\${infer Char}${infer Rest}` + ? SplitEscaped + : K extends `${D}${infer Rest}` + ? [Acc, Rest] + : K extends `${infer Char}${infer Rest}` + ? SplitEscaped + : [`${Acc}${K}`, never]; + +/** `[first key, rest of the path]`, or `[key, never]` for the last key of a path. Mirrors `pathFormat().split`. */ +export type Split = C['notation'] extends 'pointer' + ? SplitPointer + : C['notation'] extends 'bracket' + ? SplitBracket + : C['escape'] extends true + ? K extends `${infer Head}${C['delimiter']}${infer Rest}` + ? Head extends `${string}\\${string}` ? SplitEscaped : [Head, Rest] + : K extends `${string}\\${string}` ? SplitEscaped : [K, never] + : K extends `${infer Head}${C['delimiter']}${infer Rest}` + ? [Head, Rest] + : [K, never]; + +type Unpointer = ReplaceAll, '~0', '~'>; +type SplitPointer = K extends `/${infer Path}` ? (Path extends `${infer Head}/${infer Rest}` ? [Unpointer, `/${Rest}`] : [Unpointer, never]) : never; + +type SplitBracket = K extends `[${infer Inner}]${infer Rest}` + ? Acc extends '' ? [Inner, Rest extends '' ? never : Rest extends `${D}${infer After}` ? After : Rest] : [Acc, K] + : E extends true + ? K extends `\\${infer Char}${infer Rest}` + ? SplitBracket + : SplitBracketNext + : SplitBracketNext; +type SplitBracketNext = K extends `${D}${infer Rest}` + ? [Acc, Rest] + : K extends `${infer Char}${infer Rest}` + ? SplitBracket + : [`${Acc}${K}`, never]; + +/** Every key of a path, in order: `Segments<'a.b[0]', bracket>` is `['a', 'b', '0']`. */ +export type Segments

= Split extends [infer Head extends string, infer Rest] ? ([Rest] extends [never] ? [Head] : Rest extends string ? [Head, ...Segments] : [Head]) : string[]; diff --git a/src/unflatten/types.ts b/src/unflatten/types.ts index 1fb91c2..a27ba3f 100644 --- a/src/unflatten/types.ts +++ b/src/unflatten/types.ts @@ -1,5 +1,6 @@ import type { Notation } from '../shared/notation.ts'; -import type { IsAny, IsUnion, Option, Prettify, ReplaceAll } from '../shared/types.ts'; +import type { Split } from '../shared/path-types.ts'; +import type { IsAny, IsUnion, Option, Prettify } from '../shared/types.ts'; export interface UnflattenOptions { /** Splits the keys into paths. Default: `'.'`. */ @@ -37,44 +38,6 @@ interface Context { object: boolean; } -/** Walks a key that contains `\` one character at a time, mirroring `splitPath`. */ -type SplitEscaped = K extends `\\${infer Char}${infer Rest}` - ? SplitEscaped - : K extends `${D}${infer Rest}` - ? [Acc, Rest] - : K extends `${infer Char}${infer Rest}` - ? SplitEscaped - : [`${Acc}${K}`, never]; - -/** `[first key, rest of the path]`, or `[key, never]` for the last key of a path. Mirrors `pathFormat().split`. */ -type Split = C['notation'] extends 'pointer' - ? SplitPointer - : C['notation'] extends 'bracket' - ? SplitBracket - : C['escape'] extends true - ? K extends `${infer Head}${C['delimiter']}${infer Rest}` - ? Head extends `${string}\\${string}` ? SplitEscaped : [Head, Rest] - : K extends `${string}\\${string}` ? SplitEscaped : [K, never] - : K extends `${infer Head}${C['delimiter']}${infer Rest}` - ? [Head, Rest] - : [K, never]; - -type Unpointer = ReplaceAll, '~0', '~'>; -type SplitPointer = K extends `/${infer Path}` ? (Path extends `${infer Head}/${infer Rest}` ? [Unpointer, `/${Rest}`] : [Unpointer, never]) : never; - -type SplitBracket = K extends `[${infer Inner}]${infer Rest}` - ? Acc extends '' ? [Inner, Rest extends '' ? never : Rest extends `${D}${infer After}` ? After : Rest] : [Acc, K] - : E extends true - ? K extends `\\${infer Char}${infer Rest}` - ? SplitBracket - : SplitBracketNext - : SplitBracketNext; -type SplitBracketNext = K extends `${D}${infer Rest}` - ? [Acc, Rest] - : K extends `${infer Char}${infer Rest}` - ? SplitBracket - : [`${Acc}${K}`, never]; - type SplitKey = K extends string | number ? Split<`${K}`, C> : never; type Same = [A] extends [B] ? ([B] extends [A] ? true : false) : false; diff --git a/test/typescript-5.0/index.ts b/test/typescript-5.0/index.ts index c28da87..5b43bdf 100644 --- a/test/typescript-5.0/index.ts +++ b/test/typescript-5.0/index.ts @@ -1,4 +1,5 @@ import { flatten, unflatten } from '../../dist/index.js'; +import { get, set } from '../../dist/path.js'; const flat = flatten({ id: 42, @@ -26,4 +27,10 @@ const pointerId: number = pointer['/items/0/id']; const form = unflatten({ 'user[name]': 'Ada' } as { 'user[name]': string }, { notation: 'bracket' }); const formName: string = form.user.name; -export { id, city, tag, underscored, nestedCity, tags, firstId, pointerId, formName }; +const config = { db: { host: 'localhost', port: 5432 } }; +const port: number = get(config, 'db.port'); +set(config, 'db.port', 6543); +// @ts-expect-error `port` is a number +set(config, 'db.port', '6543'); + +export { id, city, tag, underscored, nestedCity, tags, firstId, pointerId, formName, port }; diff --git a/test/workers/run.js b/test/workers/run.js index 27742de..f5f3376 100644 --- a/test/workers/run.js +++ b/test/workers/run.js @@ -1,14 +1,16 @@ // Runs the built ESM bundle inside workerd (the Cloudflare Workers runtime) through Miniflare. -// `worker.js` imports `dist/index.js` and answers with the checks it ran. -import { readFileSync } from 'node:fs'; +// `worker.js` imports every entry of `dist/` and answers with the checks it ran. +import { readdirSync, readFileSync } from 'node:fs'; import { Miniflare } from 'miniflare'; +const dist = new URL('../../dist/', import.meta.url); +const bundle = readdirSync(dist) + .filter((file) => file.endsWith('.js')) + .map((file) => ({ type: 'ESModule', path: file, contents: readFileSync(new URL(file, dist), 'utf8') })); + const mf = new Miniflare({ compatibilityDate: '2026-07-01', - modules: [ - { type: 'ESModule', path: 'worker.js', contents: readFileSync(new URL('./worker.js', import.meta.url), 'utf8') }, - { type: 'ESModule', path: 'flattify.js', contents: readFileSync(new URL('../../dist/index.js', import.meta.url), 'utf8') } - ] + modules: [{ type: 'ESModule', path: 'worker.js', contents: readFileSync(new URL('./worker.js', import.meta.url), 'utf8') }, ...bundle], }); try { diff --git a/test/workers/worker.js b/test/workers/worker.js index 228ae2d..286775e 100644 --- a/test/workers/worker.js +++ b/test/workers/worker.js @@ -1,4 +1,5 @@ -import { flatten, unflatten } from './flattify.js'; +import { flatten, unflatten } from './index.js'; +import { get, set } from './path.js'; const same = (a, b) => JSON.stringify(a) === JSON.stringify(b); @@ -11,6 +12,7 @@ export default { ['unflatten', same(unflatten({ 'a.b.0': 1, 'a.b.1.c': 2 }), { a: { b: [1, { c: 2 }] } })], ['unflatten asArray', same(unflatten(flatten([{ id: 1 }, { id: 2 }]), { asArray: true }), [{ id: 1 }, { id: 2 }])], ['notations', same(flatten({ a: [{ b: 1 }] }, { notation: 'bracket' }), { 'a[0].b': 1 }) && same(unflatten({ '/a/0/b': 1 }, { notation: 'pointer' }), { a: [{ b: 1 }] })], + ['path helpers', get(set({}, 'a.b.0', 'x'), 'a.b.0') === 'x'], ['unflatten is pollution-safe', (unflatten({ '__proto__.polluted': true, 'constructor.prototype.polluted': true }), {}.polluted === undefined)], ]; const failed = checks.filter(([, ok]) => !ok).map(([name]) => name); diff --git a/tsdown.config.mts b/tsdown.config.mts index 2594bfa..38f3ae7 100644 --- a/tsdown.config.mts +++ b/tsdown.config.mts @@ -1,7 +1,7 @@ import { defineConfig } from 'tsdown'; export default defineConfig({ - entry: ['src/index.ts'], + entry: { index: 'src/index.ts', path: 'src/path/index.ts' }, format: ['esm', 'cjs'], dts: true, target: 'es2015',