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.
<form method="post">
<input name="user.name" value="Ada" />
<input name="+user.age" value="36" />
<input name="&user.admin" type="checkbox" checked />
<input name="tags[]" value="math" />
<input name="tags[]" value="code" />
</form>
parseFormData(formData)
// {
// user: {name: 'Ada', age: 36, admin: true},
// tags: ['math', 'code'],
// }
It's under 2 kB (minified and brotli-compressed), has no dependencies and works in all modern JavaScript runtimes (see Supported runtimes).
- Install
- Usage
- Field names
- API
- Errors
- Security
- Supported runtimes
- Issues
- Contributors β¨
- LICENSE
- Special Thanks
npm install parse-nested-form-data
import {parseFormData} from 'parse-nested-form-data'
const data = parseFormData(await request.formData())
parseFormData reads each entry, puts its value at the place its name tells,
and returns a new object. The input stays unchanged.
| 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} |
You can combine them, for example +orders[0].items[].price.
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.
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.
const formData = new FormData()
formData.append('+a', '1')
formData.append('+b', '1.5')
formData.append('+c', 'x')
parseFormData(formData)
// {a: 1, b: 1.5, c: NaN}
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.
const formData = new FormData()
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)
// {a: true, b: true, c: true, d: false, e: false, f: false}
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.
A name that starts with - changes the value to null, whatever the value is.
const formData = new FormData()
formData.append('-a', 'x')
parseFormData(formData)
// {a: null}
[] adds the value to the end of the array, in the order of the entries:
const formData = new FormData()
formData.append('a[]', '1')
formData.append('a[]', '2')
parseFormData(formData)
// {a: ['1', '2']}
[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:
const formData = new FormData()
formData.append('a[5]', 'second')
formData.append('a[1]', 'first')
parseFormData(formData)
// {a: ['first', 'second']}
One array can't use both [] and [0]. That throws a MixedArrayError.
The largest position is 4294967294, the largest index a JavaScript array can
hold. A larger number throws an InvalidPathError.
Arrays can hold objects and other arrays:
const formData = new FormData()
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)
// {a: [{b: '1'}, {b: '2'}], c: [['3', '4'], ['5']]}
.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:
const formData = new FormData()
formData.append('a[b]', '1')
formData.append('a.c[0].d', '2')
formData.append('a[c][1][d]', '3')
parseFormData(formData)
// {a: {b: '1', c: [{d: '2'}, {d: '3'}]}}
The text in brackets is the key exactly as written. It can contain ., [ and
], if the brackets inside are balanced:
const formData = new FormData()
formData.append('a[b.c]', '1')
formData.append('a[d[e]]', '2')
parseFormData(formData)
// {a: {'b.c': '1', 'd[e]': '2'}}
Brackets in the wrong place don't cause an error:
- A
[without a matching]stays in the key.a[[0]is the key[0ina.a[0is the keya[0. - A
]without a matching[stays in the key.a]bis the keya]b. - Text after
]starts the next key.a[0]bis the same asa[0].b. - Empty keys are skipped.
a..banda.[b]are the same asa.b, and[a]is the same asa.
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:
const formData = new FormData()
formData.append('avatar', new File(['...'], 'avatar.png'))
parseFormData(formData)
// {avatar: File}
formData.append('avatar.name', 'me.png')
parseFormData(formData)
// throws DuplicateKeyError('avatar')
Returns a new object with the parsed values.
Type: Iterable<[string, string | File]>
A FormData object, or any other list of [name, value] pairs, for example
[['a.b', 'x']] or a URLSearchParams object.
Type: object
Type: boolean
Default: false
Skip all entries whose value is an empty string ('').
const formData = new FormData()
formData.append('a', '')
formData.append('b', 'x')
parseFormData(formData, {removeEmptyString: true})
// {b: 'x'}
Type: number
Default: 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.
The value must be a positive integer or Infinity. Other values throw a
TypeError.
const formData = new FormData()
formData.append('user.address.street', 'Main St')
parseFormData(formData, {depth: 3})
// {user: {address: {street: 'Main St'}}}
formData.append('user.address.geo.lat', '52.5')
parseFormData(formData, {depth: 3})
// throws DepthLimitError('user.address.geo.lat')
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.
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:
const formData = new FormData()
formData.append('a', 'x')
formData.append('+b', '1')
parseFormData(formData, {
transformEntry: (entry, defaultTransform) => {
const {path, value} = defaultTransform(entry)
return {
path,
value: typeof value === 'string' ? value.toUpperCase() : value,
}
},
})
// {a: 'X', b: 1}
parseFormData stops at the first error. All error classes are exported:
import {
DepthLimitError,
DuplicateKeyError,
ForbiddenKeyError,
InvalidPathError,
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 |
InvalidPathError |
An array position is larger than 4294967294. |
a[4294967295] |
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, DepthLimitError and
InvalidPathError, 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.
try {
parseFormData(formData)
} catch (error) {
if (error instanceof DuplicateKeyError) {
return badRequest(`The field ${error.key} is used more than once`)
}
throw error
}
__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:
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.
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 option.
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 <script> tag, the UMD build
sets the global parseNestedFormData.
Looking to contribute? Look for the Good First Issue label, and see CONTRIBUTING.md for how to set up the project.
Please open an issue for bugs, missing documentation or unexpected behavior.
Please open an issue to suggest new features. Vote on feature requests with a π. This helps maintainers decide what to work on.
Thanks goes to these people (emoji key):
This project follows the all-contributors specification. Contributions of any kind welcome!
MIT
Special thanks to Kent C. Dodds and his match-sorter package where most of the setup is from.