The signup form on a booking platform I built suddenly stopped working. You'd click submit, nothing got sent, and the console showed a single red line that made me squint:
Uncaught TypeError: Failed to construct 'FormData': parameter 1 is not of type 'HTMLFormElement'The trigger was about as plain as it gets. I grabbed the form by its class and handed that element to FormData:
const form = document.querySelector('.signup-form');
const data = new FormData(form);Nothing looked wrong. The class was correct, spelled right, and the element existed in the DOM. I even ran console.log(form) and got back a real element — not null. Yet FormData flatly refused it.
Why this happens
The key word is "HTMLFormElement". FormData is picky: its constructor only accepts an actual <form> element. Not a <div>, not a <section>, not whatever element happens to carry the same class. If you hand it anything that isn't a <form>, it throws a TypeError immediately.
So why did my querySelector return the wrong element when the class was right? Because the markup wrapped the real form in a div that shared the same class. I reopened the template, and here's what was actually there:
<div class="signup-form">
<form id="signup">
<input name="email" type="email" />
<button type="submit">Sign up</button>
</form>
</div>See the problem? There are two different elements, but the signup-form class sits on the wrapper <div>, not on the <form>. Maybe it came from a styling component, maybe it was inherited from older markup — either way, the wrapper had stolen the class.
document.querySelector('.signup-form') returns the first element that matches in document order. And the first match is the wrapper <div>, because it sits on the outside and appears earlier. So my form variable really was valid and non-empty — it just held a <div>. FormData looked at it, realized it wasn't an HTMLFormElement, and bailed. The error was honest from the start; I was the one reading "form" as a guarantee that I was actually holding a form.
This is what makes the bug slippery. The selector "worked". No null. No typo. Everything looked right until you realize a class is not an identity — wrapper divs reuse the form's class all the time and silently shadow the real one.
The fix
The fix is to stop selecting by the ambiguous class and target the <form> element specifically. Since the form has an id, the cleanest move is to select by that id:
const form = document.querySelector('#signup');
const data = new FormData(form);Now that selector can only match one element — <form id="signup"> — and FormData accepts it without complaint.
If for some reason you have to keep using that class, force the selector to match only a form element by prefixing the tag:
const form = document.querySelector('form.signup-form');
const data = new FormData(form);form.signup-form means "a <form> element that also has the class signup-form". The wrapper div can never slip through this filter, because it isn't a <form>. Even if the markup later changes and that class keeps showing up everywhere, this selector still locks onto the real form.
And if you're inside an event handler — say, on submit — there's an even sturdier option: walk up from the element that fired the event to the nearest form with closest:
form.addEventListener('submit', (e) => {
e.preventDefault();
const realForm = e.target.closest('form');
const data = new FormData(realForm);
});e.target.closest('form') climbs up from the target until it finds the nearest <form>, without caring about classes at all. It's the most robust approach when your markup structure shifts often or is outside your control.
I went with the #signup version in the end. One line, obvious intent, and it'll never get confused with any wrapper div.
The takeaway
FormData needs a genuine <form> element — full stop. It won't pull fields out of a <div>, no matter how nice that div's class looks. So when you build a FormData object, don't select the form by a class that other elements might also wear. Target it by #id or by tag (form...), not by a class the wrapper has borrowed.
The broader lesson: in the DOM, a selector that "works" doesn't mean it found the element you meant. Classes get reused freely; tags and ids are far more honest about what a thing is. When an API demands a specific kind of element, select that element in an equally specific way — so the error doesn't have to teach you slowly, the way this one taught me.
