Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changeset/add-path-helpers.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ Add the `flattify/path` entry to read and write nested values by path, with auto
- `has`, `del`, `paths` (the paths `flatten` produces), `parsePath`, `stringifyPath` and `escapeKey`
- `Path<T>`, `Get<T, P>` and `SetValue<T, P>` types
- Every helper takes the `delimiter`, `notation` and `escape` options, and is safe against prototype pollution
- The JSON Pointer `''` is the whole document, as JSON Schema validators report errors at the root: `get(obj, '', { notation: 'pointer' })` returns `obj`
16 changes: 16 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,22 @@ jobs:
- run: pnpm exec playwright install --with-deps chromium firefox webkit
- run: pnpm run test:browser

claims:
name: Check README claims about other libraries
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
cache: pnpm

- run: pnpm install --frozen-lockfile
- run: pnpm run claims

jsr:
name: Check JSR publish (dry run)
runs-on: ubuntu-latest
Expand Down
439 changes: 325 additions & 114 deletions README.md

Large diffs are not rendered by default.

155 changes: 155 additions & 0 deletions bench/claims.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/**
* Every claim the README makes about other libraries, checked against the versions installed from
* package.json: `pnpm claims`. When an update changes a behavior, this fails and the README is fixed.
*/
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import * as esToolkit from 'es-toolkit';
import { get as esToolkitGet } from 'es-toolkit/compat';
import { flatten as flat, unflatten as flatUnflatten } from 'flat';
import { flattie } from 'flattie';
import { nestie } from 'nestie';
import { construct, crush, get as radashiGet } from 'radashi';
import { describe, expect, expectTypeOf, test } from 'vitest';
import { flatten, unflatten } from '../dist/index.js';

type Flatten = (input: object) => Record<string, unknown>;
type Unflatten = (input: Record<string, unknown>) => Record<string, unknown>;

const flatteners: Record<string, Flatten> = {
flattify: flatten,
flat: (input) => flat(input),
flattie: (input) => flattie(input),
'es-toolkit': (input) => esToolkit.flattenObject(input),
radashi: (input) => crush(input),
};
const unflatteners: Record<string, Unflatten> = {
flattify: unflatten,
flat: (input) => flatUnflatten(input),
nestie: (input) => nestie(input) as Record<string, unknown>,
radashi: (input) => construct(input) as Record<string, unknown>,
};
const roundTrips: Record<string, (input: object) => unknown> = {
flattify: (input) => unflatten(flatten(input)),
flat: (input) => flatUnflatten(flat(input)),
'flattie/nestie': (input) => nestie(flattie(input)),
radashi: (input) => construct(crush(input)),
};

const require = createRequire(import.meta.url);
// Read from disk: `flat` does not export its package.json.
const packageJson = (name: string) => JSON.parse(readFileSync(join(dirname(require.resolve(name)), 'package.json'), 'utf8')) as { type?: string; exports?: unknown };

class Point {
x = 1;
}

describe('Why flattify?', () => {
test('Paths and values inferred by TypeScript: the other libraries type the result loosely or not at all', () => {
expectTypeOf(flatten({ a: { b: 1 } })).toEqualTypeOf<{ 'a.b': number }>();
// flat: `flatten<T, R>(target: T): R`, you pass the result type
expectTypeOf(flat({ a: { b: 1 } })).toEqualTypeOf<unknown>();
expectTypeOf(flattie({ a: { b: 1 } })).toEqualTypeOf<Record<string, any>>();
expectTypeOf(esToolkit.flattenObject({ a: { b: 1 } })).toEqualTypeOf<Record<string, any>>();
// radashi: the keys are `string`, not the paths
expectTypeOf<keyof ReturnType<typeof crush<{ a: { b: number } }>>>().toEqualTypeOf<string>();
});

test('unflatten: es-toolkit has none', () => {
expect('unflattenObject' in esToolkit).toBe(false);
});

test('ESM and CommonJS: flat 6 is ESM only', () => {
expect(packageJson('flat').type).toBe('module');
expect(JSON.stringify(packageJson('flat').exports)).not.toContain('require');
});

test.each(Object.entries(roundTrips))('Keys with the delimiter survive the round trip: %s', (name, roundTrip) => {
expect(JSON.stringify(roundTrip({ 'a.b': 1 })) === JSON.stringify({ 'a.b': 1 })).toBe(name === 'flattify');
});

test.each(Object.entries(flatteners))('Keeps Date and Map as values: %s', (name, flattenWith) => {
const date = new Date(0);
const map = new Map([[1, 2]]);
const result = flattenWith({ a: { date, map } });
expect(result['a.date'] === date && result['a.map'] === map).toBe(name !== 'flattie');
});

test.each(Object.entries(flatteners))('Keeps class instances as values: %s', (name, flattenWith) => {
const point = new Point();
expect(flattenWith({ a: { point } })['a.point'] === point).toBe(name === 'flattify' || name === 'es-toolkit');
});

test.each(Object.entries(flatteners))('Keeps null values: %s', (name, flattenWith) => {
expect('a.b' in flattenWith({ a: { b: null } })).toBe(name !== 'flattie');
});

test.each(Object.entries(flatteners))('Keeps empty objects and arrays: %s', (name, flattenWith) => {
const result = flattenWith({ a: {}, b: [] });
expect('a' in result && 'b' in result).toBe(name === 'flattify' || name === 'flat' || name === 'es-toolkit');
});

test.each(Object.entries(flatteners))('Circular references: %s', (name, flattenWith) => {
const node: Record<string, unknown> = {};
node.self = node;
expect(() => flattenWith(node)).toThrow(name === 'flattify' ? new TypeError('Circular reference at "self"') : RangeError);
});

test.each(Object.entries(flatteners))('Deeply nested input (100k levels): %s', (name, flattenWith) => {
let deep: Record<string, unknown> = { leaf: 1 };
for (let i = 0; i < 100_000; i++) deep = { n: deep };
if (name === 'flattify') expect(() => flattenWith(deep)).not.toThrow();
else expect(() => flattenWith(deep)).toThrow(RangeError);
});

test.each(Object.entries(unflatteners))('Limit on array indices when unflattening: %s', (name, unflattenWith) => {
const result = unflattenWith({ 'a.5000': 1 });
expect(Array.isArray(result.a)).toBe(name !== 'flattify');
});

test('Bracket notation and JSON Pointer: the other libraries write `a.0` and have no such option', () => {
const options = { notation: 'bracket' } as never;
expect(flatten({ a: [1] }, { notation: 'bracket' })).toEqual({ 'a[0]': 1 });
expect(flat({ a: [1] }, options)).toEqual({ 'a.0': 1 });
expect(flattie({ a: [1] })).toEqual({ 'a.0': 1 });
expect(esToolkit.flattenObject({ a: [1] }, options)).toEqual({ 'a.0': 1 });
expect(crush({ a: [1] })).toEqual({ 'a.0': 1 });
});

test('get / set with autocompleted paths: the other libraries take any string', () => {
expectTypeOf(radashiGet).parameter(1).toEqualTypeOf<string>();
// es-toolkit: a path that does not exist compiles, so the editor has no paths to suggest
expect(esToolkitGet({ a: { b: 1 } }, 'not.a.path')).toBeUndefined();
expect('get' in { flat, flattie }).toBe(false);
});
});

describe('Migrating from flat', () => {
test('Key with the delimiter: split in two on the way back', () => {
expect(flat({ 'a.b': 1 })).toEqual({ 'a.b': 1 });
expect(flatUnflatten({ 'a.b': 1 })).toEqual({ a: { b: 1 } });
});

test('maxDepth: 0 means no limit', () => {
expect(flat({ a: { b: { c: 1 } } }, { maxDepth: 0 })).toEqual({ 'a.b.c': 1 });
});

test('unflatten walks into values that are objects', () => {
expect(flatUnflatten({ a: { 'b.c': 1 } })).toEqual({ a: { b: { c: 1 } } });
});

test('unflatten returns anything but an object as it is', () => {
expect(flatUnflatten('text' as never)).toBe('text');
expect(flatUnflatten(null as never)).toBe(null);
});

test('Class instances are flattened', () => {
expect(flat({ a: new Point() })).toEqual({ 'a.x': 1 });
});

test("Array indices: any number, such as '01' or '1e3'", () => {
expect(flatUnflatten({ 'a.01': 1 })).toEqual({ a: [undefined, 1] });
expect((flatUnflatten({ 'a.1e3': 1 }) as { a: unknown[] }).a).toHaveLength(1001);
});
});
29 changes: 29 additions & 0 deletions bench/size.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Size of each library's flatten/unflatten functions, bundled and minified as an app would ship them,
// then gzipped: `pnpm size`. The README table comes from this output.
import { gzipSync } from 'node:zlib';
import { build } from 'esbuild';

const entries = {
'flattify: flatten + unflatten': "export { flatten, unflatten } from './dist/index.js'",
'flattify: flatten': "export { flatten } from './dist/index.js'",
'flattify/path: get + set': "export { get, set } from './dist/path.js'",
'flat: flatten + unflatten': "export { flatten, unflatten } from 'flat'",
'flattie + nestie': "export { flattie } from 'flattie'; export { nestie } from 'nestie'",
'es-toolkit: flattenObject': "export { flattenObject } from 'es-toolkit'",
'radashi: crush + construct': "export { crush, construct } from 'radashi'",
};

console.log('| Import | min + gzip |\n| --- | --- |');
for (const [name, contents] of Object.entries(entries)) {
const { outputFiles } = await build({
stdin: { contents, resolveDir: process.cwd(), sourcefile: 'entry.js' },
bundle: true,
minify: true,
format: 'esm',
platform: 'neutral',
mainFields: ['module', 'main'],
write: false,
});
const bytes = gzipSync(outputFiles[0].contents, { level: 9 }).length;
console.log(`| ${name} | ${(bytes / 1000).toFixed(2)} kB |`);
}
9 changes: 9 additions & 0 deletions bench/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"isolatedDeclarations": false,
"declaration": false,
"types": ["node"]
},
"include": ["*.ts"]
}
6 changes: 5 additions & 1 deletion bench/vitest.config.mts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { defineConfig } from 'vitest/config';

// Benchmarks run in Node.js only, apart from the test projects of the root config.
// Benchmarks and README claims run in Node.js only, apart from the test projects of the root config.
export default defineConfig({
test: {
include: ['bench/**/*.spec.ts'],
benchmark: { include: ['bench/**/*.bench.ts'] },
// Load the built package with Node.js itself, as the other libraries are loaded from node_modules.
// Through Vitest's module runner, every import between its chunks would go through a getter.
server: { deps: { external: [/\/dist\//] } },
},
});
12 changes: 12 additions & 0 deletions docs/file-docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,15 @@ the oldest version supported: the public types rely on `const` type parameters,
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`.

### bench/
- `pnpm bench`: speed of `flatten` and `unflatten` against other libraries, to watch for regressions.
- `pnpm size`: minified and gzipped size of each library's functions, for the README table.
- `pnpm claims`: `claims.spec.ts` checks every statement the README makes about the other libraries,
against the versions installed from `package.json`. It runs in CI, so a dependency update that
changes a behavior fails the build until the README is fixed.

The other libraries are dev dependencies only: they are never part of the published package.
`vitest.config.mts` loads `dist/` with Node.js itself, as the other libraries are loaded from
`node_modules`; through Vitest's module runner, every import between flattify's chunks would go
through a getter and make it look slower than it is.
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "flattify",
"version": "1.0.2",
"description": "Flatten and unflatten nested objects with the resulting keys inferred by TypeScript. Safe against prototype pollution, zero dependencies.",
"description": "Flatten and unflatten nested objects without losing data: dates, nulls, empty objects and dotted keys survive the round trip. Typed paths, safe on untrusted input, zero dependencies.",
"author": "Fernando Machado Bernardino <fernandombernardino@gmail.com>",
"homepage": "https://github.com/NandoMB/flattify#readme",
"type": "module",
Expand Down Expand Up @@ -54,6 +54,8 @@
"test": "vitest run --project node",
"coverage": "vitest run --project node --coverage",
"bench": "pnpm run build && vitest bench --run --config bench/vitest.config.mts",
"claims": "pnpm run build && tsc -p bench && vitest run --config bench/vitest.config.mts",
"size": "pnpm run build && node bench/size.js",
"build": "tsdown",
"lint": "tsc",
"ci": "pnpm run lint && pnpm run test && pnpm run build && pnpm run lint:package",
Expand All @@ -75,6 +77,7 @@
"@vitest/browser-playwright": "^5.0.1",
"@vitest/coverage-istanbul": "^5.0.1",
"es-toolkit": "1.52.0",
"esbuild": "^0.28.2",
"fast-check": "^4.10.2",
"flat": "6.0.1",
"flattie": "1.1.1",
Expand Down
Loading
Loading