From 72a77c5e60890fe755d820c9e89ad4b952e8033d Mon Sep 17 00:00:00 2001 From: Fernando Machado Bernardino Date: Fri, 25 Sep 2026 18:35:22 -0300 Subject: [PATCH] feat: add flattify/path with typed get, set, has and del New flattify/path entry: get, set, has, del, paths, parsePath, stringifyPath and escapeKey, with autocompleted paths (Path), typed results (Get) and values checked on set (SetValue). Every helper follows the delimiter, notation and escape options and is safe against prototype pollution. A path/package.json shim makes the subpath resolve with moduleResolution node. --- .changeset/add-path-helpers.md | 11 ++ docs/file-docs.md | 5 + jsr.json | 5 +- package.json | 13 +- path/package.json | 6 + src/flatten/types.ts | 14 +- src/index.ts | 1 + src/path/index.ts | 18 +++ src/path/path.spec.ts | 271 +++++++++++++++++++++++++++++++++ src/path/path.ts | 221 +++++++++++++++++++++++++++ src/path/types.ts | 107 +++++++++++++ src/shared/path-types.ts | 50 ++++++ src/unflatten/types.ts | 41 +---- test/typescript-5.0/index.ts | 9 +- test/workers/run.js | 14 +- test/workers/worker.js | 4 +- tsdown.config.mts | 2 +- 17 files changed, 735 insertions(+), 57 deletions(-) create mode 100644 .changeset/add-path-helpers.md create mode 100644 path/package.json create mode 100644 src/path/index.ts create mode 100644 src/path/path.spec.ts create mode 100644 src/path/path.ts create mode 100644 src/path/types.ts create mode 100644 src/shared/path-types.ts 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',