ESC

Type to search the knowledge base.

Serialize a Form to JSON

Read FormData, group duplicate names into arrays, skip disabled controls, and handle file inputs without pretending they are strings.

intermediate5 min read
  • 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 with querySelectorAll.
  • Repeat names become arrays. Object.fromEntries is wrong for that.
  • Disabled controls and unchecked checkboxes never appear. Do not invent values for them.
  • File fields are File objects. 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'):

  1. You will include disabled controls unless you re-implement the spec.
  2. You will miss controls outside the form that use form="signup". FormData includes those. A descendant-only query does not.
  3. You will double-count or skip select and textarea if the selector is only 'input'.
  4. Unchecked checkboxes show up as "on" if you read .value without .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:

  1. Keep the File on the object for fetch with FormData (best for uploads). See FormData and file uploads.
  2. Store metadata (filename, size, type) if the JSON is for logging or a preview model.
  3. 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/json while the payload still holds Files. JSON cannot carry the bytes. Use FormData as the fetch body, or upload the file first.
  • Putting unchecked boxes in as false. HTML would not have submitted them. If the API wants explicit false, that is an app rule on top of FormData, not instead of it.
  • Reading .value from input[type=file]. That string is a fake path. Use .files.
  • Skipping enctype. Serialization to JSON is your code. The browser’s native submit still uses application/x-www-form-urlencoded or 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 guides