Forms, Validation & Input APIs
HTML forms are the most underused native API on the web. Before reaching for a form library, senior engineers understand what the platform gives them for free: constraint validation, data serialization, keyboard navigation, and accessibility â all without a single line of JavaScript.
Form Architecture
Every <form> has three critical attributes that control submission behavior:
<form
action="/api/submit" <!-- Where to send data -->
method="POST" <!-- HTTP method (GET or POST) -->
enctype="multipart/form-data" <!-- Encoding for file uploads -->
>| Attribute | Values | When to Use |
|---|---|---|
action | URL string | Server endpoint for form data |
method | GET, POST | GET for searches/filters (data in URL), POST for mutations |
enctype | application/x-www-form-urlencoded (default) | Text-only forms |
multipart/form-data | File uploads (required for <input type="file">) | |
text/plain | Rarely used; debugging only |
Mental model: GET forms are idempotent queries. POST forms are state-changing mutations. This aligns with REST semantics and determines whether form data appears in the URL.
Input Types: The Full Arsenal
Modern HTML provides specialized input types that trigger appropriate virtual keyboards, built-in validation, and native date/color pickers:
<!-- Text variants -->
<input type="text" /> <!-- General text -->
<input type="email" /> <!-- Email validation + @ keyboard on mobile -->
<input type="tel" /> <!-- Numeric keypad on mobile (no validation) -->
<input type="url" /> <!-- URL validation + .com keyboard on mobile -->
<input type="password" /> <!-- Masked input -->
<input type="search" /> <!-- Clearable, may have search styling -->
<!-- Numeric -->
<input type="number" min="0" max="100" step="5" /> <!-- Spinner, numeric keyboard -->
<input type="range" min="0" max="100" /> <!-- Slider -->
<!-- Date/Time -->
<input type="date" /> <!-- Native date picker -->
<input type="time" /> <!-- Time picker -->
<input type="datetime-local" /> <!-- Combined date + time -->
<input type="month" /> <!-- Month/year picker -->
<input type="week" /> <!-- Week picker -->
<!-- Other -->
<input type="color" /> <!-- Native color picker -->
<input type="file" accept=".pdf,.doc" multiple /> <!-- File upload -->
<input type="hidden" name="csrf" value="token123" /> <!-- Server data -->Interview tip: type="tel" does NOT validate phone numbers â it only changes the mobile keyboard. type="email" and type="url" DO validate format.
Constraint Validation API
The browser provides a complete validation engine via the Constraint Validation API. Every form control has a validity property:
const input = document.querySelector('input[type="email"]');
input.validity;
// ValidityState {
// valueMissing: false, // required but empty
// typeMismatch: false, // doesn't match type (email, url)
// patternMismatch: false, // doesn't match pattern attribute
// tooLong: false, // exceeds maxlength
// tooShort: false, // below minlength
// rangeUnderflow: false, // below min
// rangeOverflow: false, // above max
// stepMismatch: false, // doesn't match step
// badInput: false, // browser can't parse (e.g., letters in number)
// customError: false, // setCustomValidity() was called
// valid: true // all constraints pass
// }Validation Methods
const form = document.querySelector('form');
const emailInput = document.querySelector('#email');
// Check validity without showing browser tooltips
emailInput.checkValidity(); // returns boolean, fires 'invalid' event if false
// Check validity AND show browser tooltip
emailInput.reportValidity(); // returns boolean, shows native error UI
// Set custom validation message
emailInput.setCustomValidity('Please use your company email');
emailInput.reportValidity(); // shows custom message
// Clear custom validation (required before re-checking)
emailInput.setCustomValidity('');
// Validate entire form
form.checkValidity(); // checks all controls, returns boolean
form.reportValidity(); // checks all, shows first errorCustom Validation Patterns
Combine HTML attributes with JavaScript for robust validation:
<form id="signup" novalidate>
<label for="email">Email</label>
<input
id="email"
type="email"
required
pattern=".*@company\.com$"
aria-describedby="email-hint email-error"
/>
<span id="email-hint">Must be a @company.com address</span>
<span id="email-error" role="alert" aria-live="polite"></span>
<button type="submit">Sign Up</button>
</form>const form = document.getElementById('signup');
const email = document.getElementById('email');
const error = document.getElementById('email-error');
form.addEventListener('submit', (e) => {
e.preventDefault();
email.setCustomValidity('');
if (!email.validity.valid) {
if (email.validity.valueMissing) {
error.textContent = 'Email is required.';
} else if (email.validity.typeMismatch) {
error.textContent = 'Please enter a valid email address.';
} else if (email.validity.patternMismatch) {
error.textContent = 'Please use your @company.com email.';
}
email.setAttribute('aria-invalid', 'true');
return;
}
email.setAttribute('aria-invalid', 'false');
error.textContent = '';
// Proceed with submission
});Key insight: novalidate on the form disables browser-native tooltips but does NOT disable the Constraint Validation API. You get programmatic access without the ugly default UI.
FormData API
FormData serializes form data into key-value pairs, handling files and multi-selects automatically:
const form = document.querySelector('form');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const data = new FormData(form);
// Access values
data.get('email'); // single value
data.getAll('interests'); // array (for checkboxes/multi-select)
data.has('newsletter'); // boolean
// Modify before sending
data.append('timestamp', Date.now());
data.delete('honeypot');
// Send as multipart (for file uploads)
await fetch('/api/submit', { method: 'POST', body: data });
// Or convert to JSON
const json = Object.fromEntries(data);
await fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(json),
});
});Warning: Object.fromEntries(data) loses duplicate keys (checkboxes, multi-selects). For those, iterate explicitly or use data.getAll().
The form Attribute: Remote Inputs
Inputs don't need to be DOM children of a <form>. The form attribute associates any input with a form by ID:
<form id="checkout">
<input name="item" />
<button type="submit">Buy</button>
</form>
<!-- This input is visually elsewhere but submits with #checkout -->
<input name="promo-code" form="checkout" placeholder="Promo code" />This is powerful for complex layouts where form controls are scattered across different visual regions (modals, sidebars, sticky footers).
fieldset, legend & Form Structure
<fieldset> groups related controls; <legend> labels the group. Screen readers announce the legend before each input in the group:
<fieldset>
<legend>Shipping Address</legend>
<label for="street">Street</label>
<input id="street" name="street" required />
<label for="city">City</label>
<input id="city" name="city" required />
<label for="zip">ZIP Code</label>
<input id="zip" name="zip" pattern="[0-9]{5}" required />
</fieldset>
<fieldset disabled>
<legend>Billing Address (same as shipping)</legend>
<!-- All inputs inside are disabled -->
</fieldset>disabled on a <fieldset> disables ALL descendant form controls â a single attribute to lock an entire section.
datalist: Native Autocomplete
<datalist> provides a suggestion list for any text input â no JavaScript required:
<label for="framework">Favorite Framework</label>
<input id="framework" list="frameworks" name="framework" />
<datalist id="frameworks">
<option value="React" />
<option value="Vue" />
<option value="Angular" />
<option value="Svelte" />
<option value="SolidJS" />
</datalist>Users can still type free-form text. The datalist is a suggestion, not a constraint. For strict selection, use <select>.
The output Element
<output> represents the result of a calculation or user action:
<form oninput="total.value = parseInt(price.value) * parseInt(qty.value)">
<input id="price" name="price" type="number" value="10" /> Ã
<input id="qty" name="qty" type="number" value="1" />
= <output name="total" for="price qty">10</output>
</form><output> has an implicit role="status" and aria-live="polite", so screen readers automatically announce changes.
Form Accessibility Checklist
<!-- 1. Every input MUST have a label -->
<label for="name">Full Name</label>
<input id="name" name="name" required />
<!-- 2. Error messages linked via aria-describedby -->
<input id="email" aria-describedby="email-error" aria-invalid="true" />
<span id="email-error" role="alert">Please enter a valid email</span>
<!-- 3. Required fields announced to screen readers -->
<input id="phone" required aria-required="true" />
<!-- 4. Group related inputs with fieldset/legend -->
<fieldset>
<legend>Payment Method</legend>
<label><input type="radio" name="pay" value="card" /> Credit Card</label>
<label><input type="radio" name="pay" value="paypal" /> PayPal</label>
</fieldset>| Pattern | Purpose |
|---|---|
<label for="id"> | Associates label with input (click label â focus input) |
aria-describedby | Links supplementary text (hints, errors) to input |
aria-invalid="true" | Marks input as having an error |
aria-required="true" | Redundant with required but ensures screen reader support |
role="alert" | Announces content immediately when it appears |
aria-live="polite" | Announces content at next pause in speech |
Progressive Enhancement
Forms should work without JavaScript. The strongest architecture enhances native behavior rather than replacing it:
<!-- Works without JS: submits to server, full page reload -->
<form action="/api/register" method="POST">
<input name="email" type="email" required />
<button type="submit">Register</button>
</form>// Enhancement: intercept for SPA behavior
if ('fetch' in window) {
form.addEventListener('submit', async (e) => {
e.preventDefault();
const data = new FormData(form);
const response = await fetch(form.action, {
method: form.method,
body: data,
});
// Handle SPA response
});
}The principle: Build for the baseline, then layer on improvements. If JavaScript fails to load, the form still submits. If fetch isn't available, the form still submits. This is what separates senior form architecture from "it works on my machine."
Interview Mental Model
HTML Form Architecture
âââ Structure: form[action, method, enctype] â fieldset/legend â label + input
âââ Validation: HTML attributes â Constraint Validation API â Custom JS
âââ Data: FormData API â fetch/XHR â Server
âââ A11y: label[for] â aria-describedby â aria-invalid â role="alert"The browser gives you validation, serialization, keyboard navigation, and accessibility for free. A senior engineer's job is to enhance that â not replace it.