../

Forms

HTML forms from TypeScript: typed access to controls, reading values with FormData and Zod, the constraint validation API, submitting with fetch, file inputs, and the accessibility and security rules that go with them. React-specific patterns live in TypeScript & React.

Form elements

Finding the form

const form =
  document.querySelector<HTMLFormElement>("#signup");
if (!form) throw new Error("#signup not found");
 
document.forms.namedItem("signup"); // by name or id
input.form; // owner form: HTMLFormElement | null

form.elements

ExpressionType in lib.domNotes
form.elementsHTMLFormControlsCollectionlive, tree order, all listed controls
form.elements.lengthnumbercounts buttons, fieldsets, outputs too
form.elements[0]Elementby index
form.elements.namedItem("email")RadioNodeList | Element | nullby name or id
form.elements.namedItem("plan")RadioNodeList at runtimewhen several controls share the name
form.emailanynamed getter on the form; avoid, it can shadow submit, reset...

Listed controls: button, fieldset, input (except type="image"), object, output, select, textarea and form-associated custom elements. Controls outside the form with a form="id" attribute are included.

Typed elements by name

Declare the shape once and assert the form to it. This is an assertion: nothing checks the HTML.

interface SignupControls extends HTMLFormControlsCollection {
  email: HTMLInputElement;
  password: HTMLInputElement;
  plan: RadioNodeList; // several radios, one name
  terms: HTMLInputElement;
}
 
interface SignupForm extends HTMLFormElement {
  readonly elements: SignupControls;
}
 
const form = document.querySelector<SignupForm>("#signup");
form?.elements.email.value; // string
form?.elements.plan.value;  // checked radio's value, or ""

Checked at runtime instead, with a generic helper:

function control<T extends Element>(
  form: HTMLFormElement,
  name: string,
  type: { new (): T; readonly name: string },
): T {
  const el = form.elements.namedItem(name);
  if (!(el instanceof type)) {
    throw new TypeError(`"${name}" is not ${type.name}`);
  }
  return el;
}
 
const email = control(form, "email", HTMLInputElement);
const bio = control(form, "bio", HTMLTextAreaElement);

RadioNodeList

MemberMeaning
list.valuevalue of the checked radio; "" if none is checked
list.value = "pro"checks the radio whose value is "pro"
list.length, list[i]the individual HTMLInputElements
for (const r of list)iterable (NodeListOf<HTMLInputElement>)

namedItem returns a single Element, not a list, when only one control has that name. Handle both (el instanceof RadioNodeList) if the count can vary.

The form attribute and button overrides

<form id="search" action="/search"></form>
 
<!-- elsewhere in the page -->
<input name="q" form="search" />
<button form="search">Search</button>
On a submit buttonOverrides form attributeTypical use
formactionaction"Save" vs "Delete" endpoints
formmethodmethodGET preview, POST save
formenctypeenctypemultipart only for one button
formnovalidatenovalidate"Save draft" skips validation
formtargettargetopen result in a new tab

Reading values

FormData

form.addEventListener("submit", (event) => {
  event.preventDefault();
  // include the clicked button's name=value pair
  const data = new FormData(form, event.submitter);
 
  data.get("email");  // FormDataEntryValue | null
  data.getAll("tag"); // FormDataEntryValue[]
  data.has("terms");  // boolean
});

FormDataEntryValue is string | File. The submitter argument must be a submit button of that form (otherwise TypeError / NotFoundError); it reached all engines in 2023.

MethodReturnsNotes
get(name)FormDataEntryValue | nullfirst entry only
getAll(name)FormDataEntryValue[]checkbox groups, multi-select
has(name)booleanunchecked checkbox is absent
set(name, value)voidreplaces all entries for name
append(name, value)voidadds another entry
delete(name)voidremoves all entries
entries() / for...of[string, FormDataEntryValue] pairsalso keys(), values()
ControlIn FormData whenEntry value
text-like input, textareahas a name, enabledstring, "" when empty
checkbox, radiocheckedvalue attribute, default "on"
select multipleper selected optionone entry per option
input type="file"alwaysFile; empty file named "" if none chosen
submit buttonit is the submitterits value
disabled controlneverreadonly controls are included
no namenever

Object.fromEntries caveats

const raw = Object.fromEntries(new FormData(form));
// { [k: string]: FormDataEntryValue }
  • Duplicate names collapse: last entry wins (checkbox groups, select multiple). Use getAll.
  • Unchecked checkboxes are missing keys, not false.
  • Every value is string | File; numbers and dates stay strings.
  • Empty text fields are "", not undefined.

Parse and validate with Zod

signup-schema.ts
import { z } from "zod";
 
export const Signup = z.object({
  email: z.email(),
  age: z.coerce.number().int().min(13),
  plan: z.enum(["free", "pro"]),
  tags: z.array(z.string()).max(5),
  newsletter: z.boolean(),
  terms: z.literal("on", { error: "Accept the terms" }),
});
export type Signup = z.infer<typeof Signup>;
 
export function readSignup(data: FormData) {
  return Signup.safeParse({
    ...Object.fromEntries(data),
    tags: data.getAll("tags"),
    newsletter: data.has("newsletter"),
  });
}
const result = readSignup(new FormData(form));
if (result.success) {
  result.data.age; // number
} else {
  const { fieldErrors } = z.flattenError(result.error);
  fieldErrors.email; // string[] | undefined
}

Typed accessors on inputs

PropertyWorks onOtherwise
valueevery controlalways a string
valueAsNumbernumber, range, date, time, datetime-local, month, weekNaN
valueAsDatedate, time, month, weeknull; setting throws
checkedcheckbox, radioboolean
indeterminatecheckboxvisual only, not submitted
filesfileFileList | null
selectedOptionsselectHTMLCollectionOf<HTMLOptionElement>
const qty = control(form, "qty", HTMLInputElement);
const n = qty.valueAsNumber;
if (Number.isNaN(n)) throw new Error("not a number");
 
const due = control(form, "due", HTMLInputElement); // date
due.valueAsDate; // Date at 00:00 UTC, or null when empty
due.valueAsDate?.toISOString().slice(0, 10); // "2026-09-25"

valueAsDate is midnight UTC, so toLocaleDateString() can show the previous day west of UTC. datetime-local has no valueAsDate (it is a wall-clock time); use valueAsNumber or parse value.

Checkboxes

const terms = control(form, "terms", HTMLInputElement);
terms.checked; // boolean, the state
terms.value;   // "on" unless value="..." is set
 
const data = new FormData(form);
data.has("terms");       // true only when checked
// every checked box named "toppings"
data.getAll("toppings");

Input types

type.value looks likeBetter accessorUseful attributes
textany stringminlength maxlength pattern
email"a@b.co"; list with multiplemultiple pattern
passwordany stringminlength autocomplete
searchany stringenterkeyhint="search"
telany string, no format checkpattern inputmode implied
urlabsolute URLnew URL(value)pattern
number"42"; "" if not numericvalueAsNumbermin max step ("any")
range"50", never emptyvalueAsNumbermin max step (default 0 to 100)
date"2026-09-25"valueAsDatemin max step (days)
time"14:30" or "14:30:15"valueAsNumber (ms)min max step (seconds)
datetime-local"2026-09-25T14:30"valueAsNumbermin max step
month"2026-09"valueAsDatemin max
week"2026-W39"valueAsDatemin max
color"#rrggbb" lowercaselist for swatches
checkboxits value ("on")checkedrequired = must be checked
radioits valueRadioNodeList.valuesame name groups them
file"C:\fakepath\a.png"filesaccept multiple capture
hiddenany stringnot validated, not secret
selectselected option's valueselectedOptionsmultiple size required
textareastring, newlines as \nrows maxlength wrap

inputmode (numeric, decimal, email, tel, url, search) picks the mobile keyboard without changing validation; type="text" inputmode="numeric" suits codes and card numbers that are not quantities.

Constraint validation

Attributes and the flag they set

AttributeApplies tovalidity flag
requiredinputs, select, textareavalueMissing
minlength / maxlengthtext-like inputs, textareatooShort / tooLong
patterntext search url tel email passwordpatternMismatch
min / maxnumeric and date/time typesrangeUnderflow / rangeOverflow
stepnumeric and date/time typesstepMismatch
type="email" / "url"those typestypeMismatch

pattern must match the whole value (it is anchored for you) and is compiled with the v flag, so escape - and other class-set characters inside [...]. minlength and tooLong/tooShort only apply after a user edit, not to values set from script.

ValidityState

Propertytrue when
valueMissingrequired and empty (or checkbox unchecked, no radio chosen)
typeMismatchnot a valid email / URL for its type
patternMismatchvalue does not match pattern
tooLonglonger than maxlength (user edits only)
tooShortshorter than minlength, and not empty
rangeUnderflowbelow min
rangeOverflowabove max
stepMismatchnot on the step grid from min
badInputbrowser cannot convert the input ("1e" in a number field)
customErrora non-empty setCustomValidity message is set
validnone of the above

Methods and properties

MemberOnDoes
checkValidity()control, formboolean; fires invalid on each failing control
reportValidity()control, formsame, plus shows the browser bubble and focuses the first
setCustomValidity(msg)controlnon-empty sets customError; "" clears it
validitycontrolthe ValidityState above
validationMessagecontrollocalized browser message, "" when valid
willValidatecontrolfalse when barred: disabled, readonly, hidden, button type="button"
noValidateformreflects novalidate

Custom rules

function syncConfirm(
  password: HTMLInputElement,
  repeat: HTMLInputElement,
): void {
  const same = repeat.value === password.value;
  repeat.setCustomValidity(same ? "" : "Passwords differ");
}
 
const password = control(form, "password", HTMLInputElement);
const repeat = control(form, "repeat", HTMLInputElement);
for (const el of [password, repeat]) {
  el.addEventListener("input", () => {
    syncConfirm(password, repeat);
  });
}

novalidate and your own messages

<form novalidate> stops the browser blocking submission and showing bubbles; the API still works. The usual pattern is novalidate plus checkValidity() and your own error UI:

form.noValidate = true;
form.addEventListener("submit", (event) => {
  if (!form.checkValidity()) {
    event.preventDefault();
    focusFirstInvalid(form); // see Accessibility
  }
});
 
// `invalid` does not bubble: listen in the capture phase
form.addEventListener("invalid", (event) => {
  const el = event.target;
  if (el instanceof HTMLInputElement) {
    showFieldError(el, el.validationMessage);
  }
}, true);

CSS pseudo-classes

SelectorMatches
:invalid / :validconstraint state right now, even on page load
:user-invalid / :user-validonly after the user edited the field or tried to submit (Baseline 2023)
:required / :optionalpresence of required
:in-range / :out-of-rangemin/max checks
:placeholder-shownfield is empty and showing its placeholder
form:invalid, fieldset:invalidcontains at least one invalid control

Style errors with :user-invalid so untouched fields do not start red.

Submitting

The submit event

form.addEventListener("submit", (event) => {
  event.preventDefault();       // stop the page navigation
  event.submitter;              // HTMLElement | null
});
  • "submit" maps to SubmitEvent in HTMLElementEventMap, so event.submitter is typed.
  • It fires on the form, after constraint validation passes (unless novalidate).
  • Enter in a single-line field triggers implicit submission via the default button.
  • A button inside a form defaults to type="submit"; write type="button" for anything else.

requestSubmit() vs submit()

form.requestSubmit(btn?)form.submit()
Runs constraint validationyesno
Fires submit eventyes (listeners can cancel)no
Sets submitterbtn, or nulln/a
Use for"submit like the user did"bypassing everything

Request body: content types

bodyContent-Type fetch sendsCarries files
FormDatamultipart/form-data; boundary=... (auto)yes
URLSearchParamsapplication/x-www-form-urlencoded;charset=UTF-8no
JSON.stringify(obj)text/plain;charset=UTF-8 unless you set itno
async function post(form: HTMLFormElement, data: FormData) {
  // multipart, files included
  await fetch(form.action, { method: "POST", body: data });
 
  // urlencoded, strings only
  await fetch(form.action, {
    method: "POST",
    body: toParams(data),
  });
 
  // JSON: set the header yourself
  await fetch("/api/signup", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(Object.fromEntries(data)),
  });
}
 
// URLSearchParams(formData) fails to type-check: File values
function toParams(data: FormData): URLSearchParams {
  const params = new URLSearchParams();
  for (const [key, value] of data) {
    if (typeof value === "string") params.append(key, value);
  }
  return params;
}

form.action is the resolved absolute URL; form.method is lowercase "get", "post" or "dialog". For a GET form, put the params in the URL: url.search = toParams(data).toString().

Disabling while in flight

async function onSubmit(event: SubmitEvent): Promise<void> {
  event.preventDefault();
  const form = event.currentTarget;
  if (!(form instanceof HTMLFormElement)) return;
  if (form.ariaBusy === "true") return; // double submit
 
  const button = event.submitter;
  // before disabling
  const data = new FormData(form, button);
  const btn =
    button instanceof HTMLButtonElement ? button : null;
  if (btn) btn.disabled = true;
  form.ariaBusy = "true";
  try {
    const res = await fetch(form.action, {
      method: "POST",
      body: data,
    });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    form.reset();
  } catch (error) {
    showFormError(form, error);
  } finally {
    if (btn) btn.disabled = false;
    form.ariaBusy = null;
  }
}
 
form.addEventListener("submit", onSubmit);

Build the FormData before disabling anything: disabled controls are left out. event.currentTarget is EventTarget | null, hence the instanceof guard.

File inputs

<input
  type="file"
  name="photos"
  accept="image/png,image/jpeg"
  multiple
/>
AttributeEffect
acceptpicker filter: .pdf, image/*, image/png; a hint, not enforced
multipleallow several files
capturemobile camera: "user" (front) or "environment" (back)
webkitdirectorypick a folder (non-standard name, supported everywhere)

Reading files

const input = control(form, "photos", HTMLInputElement);
input.addEventListener("change", async () => {
  const files = Array.from(input.files ?? []);
  for (const file of files) {
    file.name;         // "cat.png", no path
    file.size;         // bytes
    file.type;         // "image/png", from the extension
    file.lastModified; // ms since epoch
  }
  const first = files[0];
  if (first?.type === "text/csv") {
    const text = await first.text();
    text.split("\n").length;
  }
});
File / Blob methodReturnsNotes
text()Promise<string>decodes as UTF-8
arrayBuffer()Promise<ArrayBuffer>raw bytes
bytes()Promise<Uint8Array>Baseline 2025
stream()ReadableStream<Uint8Array>large files without buffering
slice(start, end)Blobchunked uploads, headers
FileReadercallback APIlegacy; prefer the promise methods

Client-side checks (UX only)

const MAX_BYTES = 5 * 1024 * 1024;
 
function checkImage(file: File): string | null {
  if (!file.type.startsWith("image/")) return "Not an image";
  if (file.size > MAX_BYTES) return "Larger than 5 MB";
  return null;
}

Preview with an object URL

function preview(file: File, img: HTMLImageElement): void {
  const url = URL.createObjectURL(file);
  img.addEventListener(
    "load",
    () => URL.revokeObjectURL(url),
    { once: true },
  );
  img.src = url;
}

Revoke every object URL you create, or the file stays in memory until the page unloads.

Upload with progress

fetch has no upload progress events (streaming request bodies are Chromium-only and need HTTP/2). For a progress bar, use XMLHttpRequest:

function upload(
  url: string,
  body: FormData,
  onProgress: (fraction: number) => void,
): Promise<number> {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open("POST", url);
    xhr.upload.addEventListener("progress", (e) => {
      if (e.lengthComputable) onProgress(e.loaded / e.total);
    });
    xhr.addEventListener("load", () => resolve(xhr.status));
    xhr.addEventListener("error", () => {
      reject(new Error("Network error"));
    });
    xhr.send(body);
  });
}
 
const body = new FormData();
for (const file of files) {
  body.append("photos", file, file.name);
}
await upload("/api/photos", body, (f) => {
  bar.value = f;
});

Accessibility

Labels and groups

<label for="email">Email</label>
<input id="email" name="email" type="email" required
  autocomplete="email"
  aria-describedby="email-hint email-error" />
<p id="email-hint">Used for receipts only.</p>
<p id="email-error" hidden></p>
 
<fieldset>
  <legend>Plan</legend>
  <label>
    <input type="radio" name="plan" value="free" /> Free
  </label>
  <label>
    <input type="radio" name="plan" value="pro" /> Pro
  </label>
</fieldset>
RuleWhy
Every control has a labelaccessible name, bigger click target; placeholder is not a label
fieldset + legend for radio/checkbox groupsthe group's question is announced with each option
Hints and errors via aria-describedbyread after the label; list several ids
aria-invalid="true" on bad fieldsannounced as invalid; set only after validation ran
Error text, not only colorcolor-blind users, screen readers
Summary in a live regionrole="alert" or aria-live="polite" announces changes
Don't disable the submit button up frontusers can't discover what is missing

Showing errors and focusing the first

type Control =
  | HTMLInputElement
  | HTMLSelectElement
  | HTMLTextAreaElement;
 
function isControl(el: Element): el is Control {
  return (
    el instanceof HTMLInputElement ||
    el instanceof HTMLSelectElement ||
    el instanceof HTMLTextAreaElement
  );
}
 
function showFieldError(el: Control, message: string): void {
  const out = document.getElementById(`${el.id}-error`);
  if (out) {
    out.textContent = message; // never innerHTML
    out.hidden = message === "";
  }
  el.ariaInvalid = message === "" ? null : "true";
}
 
function focusFirstInvalid(form: HTMLFormElement): void {
  const first = Array.from(form.elements)
    .filter(isControl)
    .find((el) => !el.validity.valid);
  first?.focus();
}

autocomplete tokens

TokenField
name, given-name, family-namefull name, first, last
email, telemail, phone
usernamelogin identifier
current-passwordsign-in password (lets managers fill)
new-passwordsign-up / change (managers suggest one)
one-time-codeSMS / TOTP code (iOS autofill)
street-address, address-line1address, first line
postal-code, country-name, countrypostcode, country name, ISO code
bdaydate of birth
organizationcompany
cc-name, cc-number, cc-exp, cc-csccard holder, number, expiry, CVC
shipping ..., billing ...prefix to separate two addresses
offdisable (browsers may ignore for logins)

Security

RuleDetail
Client validation is UX, not securityanyone can send any request with curl; attributes and JS are optional to an attacker
Re-validate on the serversame Zod schema in a shared module; reject unknown keys
Never inject input into innerHTMLuse textContent, setAttribute, createElement; if HTML is required, sanitize it (DOMPurify; the Sanitizer API's setHTML is not Baseline yet)
Protect against CSRFSameSite=Lax or Strict session cookies, a per-session token in a hidden field or header, and check Origin / Sec-Fetch-Site on the server
Form encodings skip CORS preflighturlencoded, multipart and text/plain are "simple" requests another site can send with cookies
hidden is not secretusers can read and change hidden inputs
Passwords and tokens: method="post"GET puts them in the URL, history and server logs
Files: trust nothingcheck size and magic bytes server-side; ignore file.name for paths and file.type for content
route.ts
import { Signup } from "./signup-schema";
 
export async function POST(req: Request): Promise<Response> {
  const data = await req.formData();
  const result = Signup.safeParse({
    ...Object.fromEntries(data),
    tags: data.getAll("tags"),
    newsletter: data.has("newsletter"),
  });
  if (!result.success) {
    return Response.json(
      { error: "Invalid input" },
      { status: 422 },
    );
  }
  // result.data is typed and trusted from here on
  return Response.json({ ok: true }, { status: 201 });
}

Forms in React

React 19 keeps the platform model: uncontrolled inputs plus FormData usually beat per-field state. A function passed to <form action={fn}> receives the FormData, runs in a transition and resets the form on success; useActionState, useFormStatus and useOptimistic add pending and result state. Typed examples, including handler types (React.SubmitEvent<HTMLFormElement> in current @types/react), are in TypeScript & React. Event basics are in Events.

References