Serialize a Form to JSON
Read FormData, group duplicate names into arrays, skip disabled controls, and handle file inputs without pretending they are strings.
- javascript
- dom
- forms
- interview
The usual way to serialize a form to JSON is Object.fromEntries(new FormData(form)). It looks like a one-liner and loses data. Duplicate names (hobbies=ski&hobbies=chess) collapse to the last value. File inputs become {} the moment you JSON.stringify. Disabled fields are already gone, which is correct, and unchecked checkboxes are already gone, which people then “fix” by walking querySelectorAll('input') and putting "off" back in.
FormData is the source of truth. The HTML spec’s “constructing the form data set” is what browsers submit. Your JSON should start from those entries, not from a homemade walk of inputs that misses select, textarea, the form="id" association, and the submitter.
Labels still matter for the UI you are serializing. See forms, labels, and inputs. This page is the data shape after that.
Key takeaways
- Build
new FormData(form)(optionally with the submitter). Do not scrape withquerySelectorAll.- Repeat names become arrays.
Object.fromEntriesis wrong for that.- Disabled controls and unchecked checkboxes never appear. Do not invent values for them.
- File fields are
Fileobjects. Do not stringify them as if they were text.
FormData is the source of truth, not querySelectorAll
FormData already implements the successful-controls rules:
| Control | In FormData? |
|---|---|
| Text, textarea, hidden, select (chosen option) | yes, if named and not disabled |
| Checked checkbox / radio | yes (value, or "on" if no value) |
| Unchecked checkbox / radio | absent |
| Disabled anything, or descendant of a disabled fieldset | absent |
No name |
absent |
| File input | File (empty file if none chosen, per spec) |
| Submit button | only the used submitter, if you pass it |
function formToJSON(form, submitter) {
const fd =
submitter && typeof FormData === 'function'
? new FormData(form, submitter)
: new FormData(form);
const out = Object.create(null);
for (const [name, value] of fd.entries()) {
const next = value instanceof File ? fileMeta(value) : value;
if (Object.prototype.hasOwnProperty.call(out, name)) {
const cur = out[name];
out[name] = Array.isArray(cur) ? [...cur, next] : [cur, next];
} else {
out[name] = next;
}
}
return out;
}
function fileMeta(file) {
return {
kind: 'file',
filename: file.name,
size: file.size,
type: file.type,
file,
};
}
<form id="signup">
<input name="email" value="a@b.com" />
<input name="hobby" value="ski" />
<input name="hobby" value="chess" />
<input name="tos" type="checkbox" value="yes" checked />
<input name="marketing" type="checkbox" />
<input name="secret" value="x" disabled />
<input name="avatar" type="file" />
</form>
formToJSON(document.getElementById('signup'));
// {
// email: 'a@b.com',
// hobby: ['ski', 'chess'],
// tos: 'yes',
// avatar: { kind: 'file', filename: '', size: 0, type: '', file: File }
// }
marketing is missing (unchecked). secret is missing (disabled). hobby is an array because the name appeared twice. That is the behavior you want for select multiple as well: FormData already emits one entry per selected option.
Why not querySelectorAll('input, select, textarea'):
- You will include disabled controls unless you re-implement the spec.
- You will miss controls outside the form that use
form="signup".FormDataincludes those. A descendant-only query does not. - You will double-count or skip
selectandtextareaif the selector is only'input'. - Unchecked checkboxes show up as
"on"if you read.valuewithout.checked.
Walk form.elements only if the interviewer bans FormData. Then you still have to skip disabled, skip unnamed, skip unchecked box/radio, and expand select multiple. You are rewriting the spec badly.
Files, stringify, and duplicate names
A File is a Blob with a name. It is not a string. JSON.stringify({ avatar: file }) yields '{"avatar":{}}' in practice: enumerable own fields on File do not include the contents. Pretending String(file) is useful gives "[object File]".
Pick a policy and state it:
- Keep the
Fileon the object forfetchwithFormData(best for uploads). See FormData and file uploads. - Store metadata (
filename,size,type) if the JSON is for logging or a preview model. - Skip empty files if the product should omit an unused avatar field.
Do not base64 the file on the main thread in an interview unless they ask. That is a different problem (Blob, File, and object URLs).
Duplicate names: first write stores a scalar, second write upgrades to an array. Do not upgrade a single checkbox to ['yes'] just in case. Only group when you actually saw a second entry. Some APIs want hobby[] keys; that is a backend convention, not HTML. Strip or keep the brackets only if the prompt says so.
JSON.stringify on the result is a separate step from building the object. Cycles will not appear here. undefined values will disappear if you assign them; FormData values are strings or File, so you are fine. Details live with JSON parse / stringify pitfalls and the JSON.stringify implementation prompt.
Submitter: new FormData(form, submitter) includes the clicked button’s name/value. Without it, two submit buttons are indistinguishable. The submit event’s submitter is the argument to pass.
Footguns
Object.fromEntries(fd)last-wins on repeated keys.- Setting
Content-Type: application/jsonwhile the payload still holdsFiles. JSON cannot carry the bytes. UseFormDataas thefetchbody, or upload the file first. - Putting unchecked boxes in as
false. HTML would not have submitted them. If the API wants explicitfalse, that is an app rule on top of FormData, not instead of it. - Reading
.valuefrominput[type=file]. That string is a fake path. Use.files. - Skipping
enctype. Serialization to JSON is your code. The browser’s native submit still usesapplication/x-www-form-urlencodedor multipart depending on the form. Do not mix those stories.
Native constraint attributes (required, type="email") are orthogonal: validate before serialize if you need to. See form validation attributes.
Interview answer (out loud)
“I construct FormData from the form, optionally passing the submitter, and fold entries into an object. The second time I see a name I upgrade that field to an array. Disabled controls and unchecked checkboxes are already omitted, which matches HTML submit. File inputs stay File objects or metadata; I would not JSON.stringify them as empty objects. I would not querySelectorAll inputs, because that misses form-associated fields and reimplements successful-control rules by accident.”
Further reading
Related on this site
- Forms, labels, and inputs
- FormData and file uploads
- Form validation attributes
- Blob, File, and object URLs
- Implement JSON.stringify
- JSON parse and stringify pitfalls
Related guides
- CSS Selector GeneratorBuild a unique CSS path from a node to the root: id when unique, then nth-child among siblings, and stop before you invent a full query engine.
- Walk the DOM Like getElementsByClassNameDFS or BFS a subtree for class tokens. Live vs static NodeList, classList vs className split, and why querySelectorAll is the modern answer.
- HTML Escape and EncodeReplace the five XML characters in user text. This is encoding, not a sanitizer, and innerHTML of escaped text is still the wrong default.
- Analytics SDK Event QueueQueue analytics events, send them in series, retry the ones that fail, and flush on page hide without dropping the last click.
- Arrays That Emit Change EventsWrap push, pop, and splice so subscribers hear adds and removes. Proxy vs subclass, and why mutating via index assignment still slips through.