From 2d204c5496ac8a88c736ce6669085fca6eaf0653 Mon Sep 17 00:00:00 2001 From: Christian Schurr Date: Mon, 9 Nov 2026 11:58:04 +0100 Subject: [PATCH] docs: rewrite the README in simpler English and fix inaccuracies Restructure the README into Install, Usage, Field names, API, Errors, Security and Supported runtimes, with short sentences and one term per concept (field name, path, path part). - Say the library works in all modern runtimes instead of "a tiny node module", and list the runtimes. - Add an Errors section: when each error is thrown, what `key` holds, that it's cut to 100 characters for ForbiddenKeyError and DepthLimitError, and that it's the path after transformEntry. - Document `+''` => 0, numbers other than 0 for `&`, unchecked checkboxes, and URLSearchParams as input. - Remove the qs mentions from the README, JSDoc and comments. --- README.md | 515 +++++++++++++++++++++-------------------- src/__tests__/index.ts | 2 +- src/index.ts | 7 +- src/path-parts.ts | 2 +- 4 files changed, 270 insertions(+), 256 deletions(-) diff --git a/README.md b/README.md index 45d7f37..ea94ced 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@

parse-nested-form-data

-

A tiny node module for parsing FormData by name into objects and arrays

+

Parse FormData into nested objects and arrays, based on the field names

--- @@ -17,35 +17,52 @@ [![Code of Conduct][coc-badge]][coc] -## The problem - -1. You use Forms to upload data to your server (e.g. with remix-run) -2. You want a way to upload objects and arrays with booleans and numbers -3. You do not want to transfer the data per hand +A form sends flat name-value pairs. This library turns them into nested objects +and arrays, with numbers, booleans and `null`. The field names tell it how. + +```html +
+ + + + + +
+``` -## This solution +```ts +parseFormData(formData) +// { +// user: {name: 'Ada', age: 36, admin: true}, +// tags: ['math', 'code'], +// } +``` -A parse functions that uses the name prop to tells the parser how to create an -object out of the values. You can tell the parser to do not parse empty values. +It has no dependencies and works in all modern JavaScript runtimes (see +[Supported runtimes](#supported-runtimes)). -- [Installation](#installation) -- [Name](#name) - - [Boolean](#boolean) - - [Number](#number) +- [Install](#install) +- [Usage](#usage) +- [Field names](#field-names) + - [Numbers](#numbers) + - [Booleans](#booleans) - [Null](#null) - - [Array](#array) - - [Object](#object) + - [Arrays](#arrays) + - [Objects](#objects) - [Files](#files) -- [Basic Usage](#basic-usage) - - [Transform values](#transform-values) -- [Advanced usage](#advanced-usage) - - [Options](#options) - - [removeEmptyString: `[boolean]`](#removeemptystring-boolean) - - [depth: `[number]`](#depth-number) - - [transformEntry: `(entry: [path: string, value: string | File], defaultTransform: DefaultTransform) => {path: string, value: JsonLeafValue}`](#transformentry-entry-path-string-value-string--file-defaulttransform-defaulttransform--path-string-value-jsonleafvalue) +- [API](#api) + - [parseFormData(formData, options?)](#parseformdataformdata-options) + - [formData](#formdata) + - [options](#options) + - [removeEmptyString](#removeemptystring) + - [depth](#depth) + - [transformEntry](#transformentry) +- [Errors](#errors) +- [Security](#security) +- [Supported runtimes](#supported-runtimes) - [Issues](#issues) - [🐛 Bugs](#-bugs) - [💡 Feature Requests](#-feature-requests) @@ -55,330 +72,216 @@ object out of the values. You can tell the parser to do not parse empty values. -## Installation - -This module is distributed via [npm][npm] which is bundled with [node][node] and -should be installed as one of your project's `dependencies`: +## Install ```sh npm install parse-nested-form-data ``` -and then import it in your project: +## Usage ```ts import {parseFormData} from 'parse-nested-form-data' + +const data = parseFormData(await request.formData()) ``` -## Name +`parseFormData` reads each entry, puts its value at the place its name tells, +and returns a new object. The input stays unchanged. -The name prop is used to tell the parser how to create an object out of the -values. The name prop is a string that can contain the following characters: +## Field names -- `.`: to create a nested object -- `[$key]`: to create a nested object (same as `.$key`) -- `[]`: to create an array (pushes value) -- `[$order]`: to create an array (sets value at index and squashes array) -- `&`: to transform the value to a boolean -- `-`: to transform the value to null -- `+`: to transform the value to a number +| In the name | Meaning | Name and value | Result | +| ---------------- | ------------------------------------ | -------------- | --------------- | +| `.key` | A key in an object | `a.b` = `x` | `{a: {b: 'x'}}` | +| `[key]` | A key in an object, same as `.key` | `a[b]` = `x` | `{a: {b: 'x'}}` | +| `[]` | Add the value to the end of an array | `a[]` = `x` | `{a: ['x']}` | +| `[0]`, `[1]`, … | Put the value at this array position | `a[0]` = `x` | `{a: ['x']}` | +| `+` at the start | Change the value to a number | `+a` = `36` | `{a: 36}` | +| `&` at the start | Change the value to a boolean | `&a` = `on` | `{a: true}` | +| `-` at the start | Change the value to `null` | `-a` = `x` | `{a: null}` | -### Boolean +You can combine them, for example `+orders[0].items[].price`. -If the name prop starts with a `&` the value will be transformed to a boolean. +In this README, the name without its `+`, `&` or `-` is the **path**. Each +`key`, `.key`, `[key]`, `[]` and `[0]` in it is a **path part**: `a.b[0]` has +three path parts. -True if the value has the following values: +### Numbers -- `true` -- `1` -- `on` +A name that starts with `+` changes the value with `Number(value)`. A value that +isn't a number gives `NaN`. An empty string gives `0`. ```ts const formData = new FormData() -formData.append('&isTrue', 'true') -formData.append('&isFalse', 'false') -formData.append('&isTrue1', '1') -formData.append('&isFalse1', '0') -formData.append('&isTrue2', 'on') -formData.append('&isFalse2', 'off') +formData.append('+a', '1') +formData.append('+b', '1.5') +formData.append('+c', 'x') parseFormData(formData) -// { -// isTrue: true, -// isFalse: false, -// isTrue1: true, -// isFalse1: false, -// isTrue2: true, -// isFalse2: false, -// } +// {a: 1, b: 1.5, c: NaN} ``` -### Number +### Booleans -If the name prop starts with a `+` the value will be transformed to a number. +A name that starts with `&` changes the value to a boolean. The value `on`, +`true` or any number other than `0` gives `true`. All other values give `false`. ```ts const formData = new FormData() -formData.append('+number', '1') -formData.append('+number2', '1.1') -formData.append('+number3', 'a') +formData.append('&a', 'on') +formData.append('&b', 'true') +formData.append('&c', '1') +formData.append('&d', 'off') +formData.append('&e', 'false') +formData.append('&f', '0') parseFormData(formData) -// { -// number: 1, -// number2: 1.1, -// number3: NaN, -// } +// {a: true, b: true, c: true, d: false, e: false, f: false} ``` -### Null - -If the name prop starts with a `-` the value will be transformed to null. - -```ts -const formData = new FormData() -formData.append('-null', 'null') -formData.append('-ignored', 'ignored') -parseFormData(formData) -// { -// null: null, -// ignored: null, -// } -``` +A checkbox sends `on` when it's checked. When it isn't checked, the browser +sends nothing, so the key is missing from the result. -### Array +### Null -If a path of the name prop ends with `[]` the value will be pushed to an array. -If the path of the name prop ends with `[$order]` the value will be set at the -index of the order. Cannot be mixed with `[]`. +A name that starts with `-` changes the value to `null`, whatever the value is. ```ts const formData = new FormData() -formData.append('array[0]', '1') -formData.append('array[1]', '2') -formData.append('array[2]', '3') -formData.append('array[3]', '4') +formData.append('-a', 'x') parseFormData(formData) -// { -// array: ['1', '2', '3', '4'], -// } +// {a: null} ``` -```ts -const formData = new FormData() -formData.append('array[]', '1') -formData.append('array[]', '2') -formData.append('array[]', '3') -parseFormData(formData) -// { -// array: ['1', '2', '3'], -// } -``` +### Arrays -Also works with nested objects: +`[]` adds the value to the end of the array, in the order of the entries: ```ts const formData = new FormData() -formData.append('array[0].a', '1') -formData.append('array[1].a', '2') -formData.append('array[2].a', '3') +formData.append('a[]', '1') +formData.append('a[]', '2') parseFormData(formData) -// { -// array: [{a: '1'}, {a: '2'}, {a: '3'}], -// } +// {a: ['1', '2']} ``` -Also works with nested arrays: +`[0]`, `[1]`, … put the value at that position. The order of the entries doesn't +matter. Gaps are removed, so `a[1]` and `a[5]` give an array of two values: ```ts const formData = new FormData() -formData.append('array[0][]', '1') -formData.append('array[0][]', '2') -formData.append('array[1][]', '3') -formData.append('array[1][]', '4') +formData.append('a[5]', 'second') +formData.append('a[1]', 'first') parseFormData(formData) -// { -// array: [['1', '2'], ['3', '4']], -// } +// {a: ['first', 'second']} ``` -### Object +One array can't use both `[]` and `[0]`. That throws a `MixedArrayError`. -If the name prop contains a `.` the value will be nested in an object. +Arrays can hold objects and other arrays: ```ts const formData = new FormData() -formData.append('object.a', '1') -formData.append('object.b', '2') +formData.append('a[0].b', '1') +formData.append('a[1].b', '2') +formData.append('c[0][]', '3') +formData.append('c[0][]', '4') +formData.append('c[1][]', '5') parseFormData(formData) -// { -// object: { -// a: '1', -// b: '2', -// }, -// } +// {a: [{b: '1'}, {b: '2'}], c: [['3', '4'], ['5']]} ``` -A key in brackets works the same as a key after a `.`, so `object[a]` and -`object.a` refer to the same key. Only digits in brackets (`[0]`) create an -array. Both forms can be mixed with each other and with arrays: +### Objects -```ts -const formData = new FormData() -formData.append('object[a]', '1') -formData.append('object.b[0].c', '2') -formData.append('object[b][1][c]', '3') -parseFormData(formData) -// { -// object: { -// a: '1', -// b: [{c: '2'}, {c: '3'}], -// }, -// } -``` - -The text in brackets is used as the key as it is, so it can contain `.`, `[` and -`]` (brackets inside must be balanced): +`.key` and `[key]` mean the same thing, so `a.b` and `a[b]` are the same key. +Only digits in brackets (`[0]`) make an array. You can mix both forms with each +other and with arrays: ```ts const formData = new FormData() -formData.append('object[a.b]', '1') -formData.append('object[c[d]]', '2') +formData.append('a[b]', '1') +formData.append('a.c[0].d', '2') +formData.append('a[c][1][d]', '3') parseFormData(formData) -// { -// object: { -// 'a.b': '1', -// 'c[d]': '2', -// }, -// } +// {a: {b: '1', c: [{d: '2'}, {d: '3'}]}} ``` -Paths with misplaced brackets are parsed without an error: - -- A `[` that no `]` closes stays in the key: `a[[0]` is the key `[0` in `a`, and - `a[0` is the key `a[0`. -- A `]` that doesn't close a `[` is part of the key: `a]b` is the key `a]b`. -- Text after `]` is the next key: `a[0]b` is `a[0].b`. -- Empty keys are skipped: `a..b`, `a.[b]` and `a[b].[c]` are `a.b`, `a.b` and - `a.b.c`, and `[a]` is `a`. - -`__proto__`, `constructor` and `prototype` can't be used as an object path part, -at any position, with or without brackets, because they could modify -`Object.prototype`. `parseFormData` throws a `ForbiddenKeyError` with the path -up to that part, cut to 100 characters plus `...` when it's longer: +The text in brackets is the key exactly as written. It can contain `.`, `[` and +`]`, if the brackets inside are balanced: ```ts const formData = new FormData() -formData.append('user.__proto__.isAdmin', 'true') +formData.append('a[b.c]', '1') +formData.append('a[d[e]]', '2') parseFormData(formData) -// throws ForbiddenKeyError('user.__proto__') +// {a: {'b.c': '1', 'd[e]': '2'}} ``` -The name of any other property on `Object.prototype` at parse time (`toString`, -`hasOwnProperty`, ..., including any added by a newer runtime or by other code) -would shadow a built-in method on the parsed object, so every entry with such a -name in its path is removed, like [`qs`](https://github.com/ljharb/qs) does. The -entry is parsed like any other entry first, so it causes the same errors as an -unused name. An error therefore doesn't tell the client which properties exist -on the server's `Object.prototype`: +Brackets in the wrong place don't cause an error: -```ts -const formData = new FormData() -formData.append('user.name', 'Ada') -formData.append('user.toString', 'x') -formData.append('settings.valueOf', 'x') -parseFormData(formData) -// {user: {name: 'Ada'}} -``` +- A `[` without a matching `]` stays in the key. `a[[0]` is the key `[0` in `a`. + `a[0` is the key `a[0`. +- A `]` without a matching `[` stays in the key. `a]b` is the key `a]b`. +- Text after `]` starts the next key. `a[0]b` is the same as `a[0].b`. +- Empty keys are skipped. `a..b` and `a.[b]` are the same as `a.b`, and `[a]` is + the same as `a`. ### Files -`File` values are returned as they are, whether they are native or come from a -polyfill such as `@remix-run/web-file`. A file is a leaf value, so a path can't -continue below it: +`File` values stay as they are. This includes native files and files from a +polyfill such as `@remix-run/web-file`. A file is always the end of a path: ```ts const formData = new FormData() formData.append('avatar', new File(['...'], 'avatar.png')) parseFormData(formData) -// { avatar: File } +// {avatar: File} formData.append('avatar.name', 'me.png') parseFormData(formData) // throws DuplicateKeyError('avatar') ``` -## Basic Usage +## API -### Transform values +### parseFormData(formData, options?) -```ts -const formData = new FormData() -formData.append('+a', '1') -formData.append('&b', 'true') -formData.append('-c', 'null') -formData.append('d', 'foo') -parseFormData(formData) -// => {a: 1, b: true, c: null, d: 'foo'} -``` +Returns a new object with the parsed values. -## Advanced usage +#### formData -Complex examples: +Type: `Iterable<[string, string | File]>` -```ts -const formData = new FormData() -formData.append('a[].b.c', '1') -formData.append('a[].b.d', '2') -formData.append('a[]', '3') -formData.append('b[0]', '4') -formData.append('+b[100]', '5') -parseFormData(formData) -// { -// a: [{b: {c: '1'}}, {b: {d: '2'}}, '3'], -// b: ['4', 5], -// } -``` +A `FormData` object, or any other list of `[name, value]` pairs, for example +`[['a.b', 'x']]` or a `URLSearchParams` object. -### Options +#### options -The second argument is an option object with following properties: +Type: `object` -### removeEmptyString: `[boolean]` +##### removeEmptyString -_Default: `false`_ +Type: `boolean`\ +Default: `false` -If `true` empty strings will be removed from the result. +Skip all entries whose value is an empty string (`''`). ```ts const formData = new FormData() -formData.append('foo[1].foo', '') -formData.append('foo[1].bar', 'test2') -formData.append('foo[0].foo', 'test3') -formData.append('foo[0].baz', '4') - +formData.append('a', '') +formData.append('b', 'x') parseFormData(formData, {removeEmptyString: true}) -// {foo: [{foo: 'test3', baz: '4'}, {bar: 'test2'}]} +// {b: 'x'} ``` -### depth: `[number]` +##### depth -_Default: `Infinity`_ +Type: `number`\ +Default: `Infinity` -The maximum number of path parts in a path. Every part counts, so `a.b[0]` and -`a[b][]` both have 3. A path with more parts throws a `DepthLimitError` with the -path up to the first part over the limit, cut to 100 characters plus `...` when -it's longer. Within one entry the depth is checked first, so a path that's too -deep throws `DepthLimitError` even if it would also throw another error, like a -`ForbiddenKeyError` for `__proto__`. Entries are parsed in order, so an earlier -entry's error wins. The depth is checked after `transformEntry`, and with -`removeEmptyString` an entry with an empty string is removed before its depth is -checked, so it never throws. Must be a positive integer or `Infinity`, otherwise -`parseFormData` throws a `TypeError`. - -The parser itself handles any depth, but a field like `a.a.a…` with thousands of -parts creates an object that crashes code walking it recursively -(`JSON.stringify`, validators, deep clones). When the input comes from untrusted -clients, set a limit; `depth: 32` fits nearly every form. Version 2.0 will lower -the default from `Infinity`. +The maximum number of path parts in one path. All parts count, so `a.b[0]` and +`a[b][]` both have three. A longer path throws a [`DepthLimitError`](#errors). +The value must be a positive integer or `Infinity`. Other values throw a +`TypeError`. ```ts const formData = new FormData() @@ -391,12 +294,30 @@ parseFormData(formData, {depth: 3}) // throws DepthLimitError('user.address.geo.lat') ``` -### transformEntry: `(entry: [path: string, value: string | File], defaultTransform: DefaultTransform) => {path: string, value: JsonLeafValue}` +The parser can handle any depth. But a field like `a.a.a…` with thousands of +parts makes an object that crashes code that walks it recursively, for example +`JSON.stringify`, validators or deep clones. If the input comes from untrusted +clients, set a limit. `depth: 32` is enough for almost every form. Version 2.0 +will change the default to a smaller number. + +The depth is checked after `transformEntry`. An entry that `removeEmptyString` +skips is never checked. + +##### transformEntry + +Type: +`(entry: [path: string, value: string | File], defaultTransform) => {path: string, value: string | number | boolean | null | File}`\ +Default: +the built-in transform for `+`, `&` and `-` + +Changes each entry before it's parsed. It gets the entry as `[name, value]` and +the built-in transform. It must return the path and the value for the result. +Call `defaultTransform(entry)` to keep `+`, `&` and `-` working: ```ts const formData = new FormData() -formData.append('a', 'a') -formData.append('b', 'b') +formData.append('a', 'x') +formData.append('+b', '1') parseFormData(formData, { transformEntry: (entry, defaultTransform) => { const {path, value} = defaultTransform(entry) @@ -406,9 +327,101 @@ parseFormData(formData, { } }, }) -// => {a: 'A', b: 'B'} +// {a: 'X', b: 1} +``` + +## Errors + +`parseFormData` stops at the first error. All error classes are exported: + +```ts +import { + DepthLimitError, + DuplicateKeyError, + ForbiddenKeyError, + MixedArrayError, +} from 'parse-nested-form-data' +``` + +| Error | Thrown when | Example names | +| ------------------- | ------------------------------------------------------------------------ | ---------------------------------------- | +| `DuplicateKeyError` | Two entries set the same path, or use a path part in two different ways. | `a` and `a`; `a` and `a.b`; `a.b`, `a[]` | +| `MixedArrayError` | One array uses both `[]` and `[0]`. | `a[]` and `a[0]` | +| `ForbiddenKeyError` | A path part is `__proto__`, `constructor` or `prototype`. | `__proto__.x`; `a[constructor]` | +| `DepthLimitError` | A path has more path parts than the `depth` option allows. | `a.b.c` with `depth: 2` | +| `TypeError` | The `depth` option isn't a positive integer or `Infinity`. | `depth: 0` | + +Each library error has a `key` property. It's the path up to the path part that +caused the error. For example, `a.b` = `y` and then `a.b[0]` = `x` throw +`DuplicateKeyError('a.b')`. In `ForbiddenKeyError` and `DepthLimitError`, a +`key` longer than 100 characters is cut to 100 characters plus `...`. + +`key` is the path after `transformEntry`, not the field name. So it doesn't have +the `+`, `&` or `-`: `+a.b.c` with `depth: 2` throws `DepthLimitError('a.b.c')`. +A custom `transformEntry` can change the path completely. If you use `key` to +find the form field, undo your own transform first. + +The entries are parsed in order, so the error of an earlier entry comes first. +In one entry, the depth is checked first: a path that's too deep throws +`DepthLimitError`, even if it has `__proto__` in it. + +```ts +try { + parseFormData(formData) +} catch (error) { + if (error instanceof DuplicateKeyError) { + return badRequest(`The field ${error.key} is used more than once`) + } + throw error +} ``` +## Security + +`__proto__`, `constructor` and `prototype` could change `Object.prototype`. So a +path part with one of these names throws a `ForbiddenKeyError`, in every +position, with or without brackets: + +```ts +const formData = new FormData() +formData.append('user.__proto__.isAdmin', 'true') +parseFormData(formData) +// throws ForbiddenKeyError('user.__proto__') +``` + +Other names that exist on `Object.prototype` at parse time (`toString`, +`hasOwnProperty`, …, and names that a newer runtime or other code adds) would +hide a built-in method of the result. So the parser removes every entry with +such a name in its path. It parses the entry like any other entry first, so the +entry causes the same errors as any other name. Because of this, an error +doesn't tell the client which properties exist on the server's +`Object.prototype`. + +```ts +const formData = new FormData() +formData.append('user.name', 'Ada') +formData.append('user.toString', 'x') +formData.append('settings.valueOf', 'x') +parseFormData(formData) +// {user: {name: 'Ada'}} +``` + +For input from untrusted clients, also set the [`depth`](#depth) option. + +## Supported runtimes + +The library uses only ES2020 and no runtime APIs. It works in: + +- browsers +- Node.js (CI tests versions 22, 24 and 26) +- Deno +- Bun +- Cloudflare Workers +- Vercel (Edge and Node.js runtimes) + +The package has ESM, CommonJS and UMD builds. In a `