Type the validation state of a controlled form
Canonical URL: https://devexamples.com/typescript/type-a-controlled-form-validation-state/
The problem
A controlled form holds values, per-field error messages, and per-field touched flags in state, and the three have to stay in step as fields are added or renamed. The markup is a component that takes a field name, so a message attached to a name that no longer exists still renders, and the reader sees an error for a field that is gone.
Short answer
Derive the field name union from the values type with keyof, type errors and touched flags as partial records over that union, map DOM names back through a checked function, and reveal a message only when its field is touched or the form was submitted.
Field names in a form exist in three places at once: the values object, the error messages, and the markup that renders a control. Typing the last two as functions of the first is what stops them from drifting apart.
// src/sign-up-rules.ts
export type SignUpValues = {
readonly email: string;
readonly password: string;
readonly confirm: string;
};
export type FieldName = keyof SignUpValues;
export type FieldErrors = Partial<Record<FieldName, string>>;
export type FieldTouched = Partial<Record<FieldName, boolean>>;
export function fieldFromName(name: string): FieldName | null {
switch (name) {
case "email":
case "password":
case "confirm":
return name;
default:
return null;
}
}
export function validateSignUp(values: SignUpValues): FieldErrors {
const errors: FieldErrors = {};
if (values.email.trim() === "") {
errors.email = "Enter an email address.";
} else if (!values.email.includes("@")) {
errors.email = "Enter an email address with an at sign.";
}
if (values.password.length < 12) {
errors.password = "Use at least 12 characters.";
}
if (values.confirm !== values.password) {
errors.confirm = "The two passwords do not match.";
}
return errors;
}The component reads that contract and keeps the accessibility wiring next to the field that owns it.
// src/sign-up-form.tsx
import { useMemo, useState, type ChangeEvent, type FocusEvent, type FormEvent } from "react";
import { fieldFromName, validateSignUp } from "./sign-up-rules";
import type { FieldErrors, FieldName, FieldTouched, SignUpValues } from "./sign-up-rules";
const emptyValues: SignUpValues = { email: "", password: "", confirm: "" };
type FieldProps = {
readonly name: FieldName;
readonly label: string;
readonly type: "email" | "password" | "text";
readonly value: string;
readonly error: string | undefined;
readonly onChange: (event: ChangeEvent<HTMLInputElement>) => void;
readonly onBlur: (event: FocusEvent<HTMLInputElement>) => void;
};
function Field({ name, label, type, value, error, onChange, onBlur }: FieldProps) {
const errorId = `${name}-error`;
return (
<p className="field">
<label htmlFor={name}>{label}</label>
<input
id={name}
name={name}
type={type}
value={value}
onChange={onChange}
onBlur={onBlur}
aria-invalid={error === undefined ? undefined : true}
aria-describedby={error === undefined ? undefined : errorId}
/>
{error === undefined ? null : (
<span id={errorId} className="field-error">
{error}
</span>
)}
</p>
);
}
type SignUpFormProps = {
readonly onAccepted: (values: SignUpValues) => void;
};
export function SignUpForm({ onAccepted }: SignUpFormProps) {
const [values, setValues] = useState<SignUpValues>(emptyValues);
const [touched, setTouched] = useState<FieldTouched>({});
const [revealAll, setRevealAll] = useState(false);
const errors: FieldErrors = useMemo(() => validateSignUp(values), [values]);
function errorFor(field: FieldName): string | undefined {
const message = errors[field];
if (message === undefined) {
return undefined;
}
return revealAll || touched[field] === true ? message : undefined;
}
function handleChange(event: ChangeEvent<HTMLInputElement>): void {
const field = fieldFromName(event.target.name);
if (field === null) {
return;
}
const value = event.target.value;
setValues((current) => ({ ...current, [field]: value }));
}
function handleBlur(event: FocusEvent<HTMLInputElement>): void {
const field = fieldFromName(event.target.name);
if (field === null) {
return;
}
setTouched((current) => ({ ...current, [field]: true }));
}
function handleSubmit(event: FormEvent<HTMLFormElement>): void {
event.preventDefault();
setRevealAll(true);
if (Object.keys(errors).length > 0) {
return;
}
onAccepted(values);
}
return (
<form onSubmit={handleSubmit} noValidate>
<Field
name="email"
label="Email"
type="email"
value={values.email}
error={errorFor("email")}
onChange={handleChange}
onBlur={handleBlur}
/>
<Field
name="password"
label="Password"
type="password"
value={values.password}
error={errorFor("password")}
onChange={handleChange}
onBlur={handleBlur}
/>
<Field
name="confirm"
label="Confirm password"
type="password"
value={values.confirm}
error={errorFor("confirm")}
onChange={handleChange}
onBlur={handleBlur}
/>
<button type="submit">Create account</button>
</form>
);
}Explanation
The chain starts with type FieldName = keyof SignUpValues, because that single line makes every other type in the file follow the shape of the form. FieldErrors is Partial of a Record over that union, which says three useful things at once: only real field names are allowed as keys, a message is string rather than string | undefined when it is present, and an absent key is normal rather than an error, so validateSignUp assigns only the fields that failed and never writes undefined into the record. errors[field] in errorFor then reads as string | undefined, which is why the check there is against undefined rather than against an empty string. Renaming confirm to confirmPassword breaks the assignments in the validator, the switch that maps DOM names, and the three call sites in the markup, and lists them all, instead of leaving a message keyed on a name no input carries.
The DOM side of the boundary needs a runtime check, and types alone cannot provide it. event.target.name is a string produced by the document, not by the compiler, so fieldFromName is the place where that string is tested against the known names and narrowed. The switch returns name directly in the matching cases, which works because TypeScript narrows the parameter to the union of the compared literals; anything else returns null and the handler bails out. The tempting shortcut is a cast from the name to FieldName, and it compiles identically while silently accepting a value the form never declared — for instance a checkbox a teammate adds to the same markup, whose name would then be spread into the values object and disappear into state that no type describes. The same reasoning applies to setValues using an updater: spreading current keeps the other fields intact without repeating their names, so a new field cannot be dropped by a handler that was written before it existed.
Reveal timing is the state-machine half of this example. Errors are derived from values on every render, which means they would otherwise be correct and invisible at the same moment, so two flags decide what the reader sees: touched per field, set on blur, and revealAll, set by a submit attempt. errorFor combines them, and the submit handler sets revealAll before checking Object.keys(errors).length, so the first failed submit both lists every problem and leaves the caret where it was; the browser is kept out of it with noValidate on the form, since its own bubbles and this message list would otherwise disagree. A boundary worth deciding deliberately: once revealAll is true it stays true, so later keystrokes report errors as they are fixed, which is usually what you want but is noisy for a password field typed character by character — the fix is to clear that flag when the field count changes, not to hide messages the reader has already been shown.
Usage notes
- Add a field by adding it to the values type only; the name union, the error record, and the switch that maps DOM names all follow from that one edit.
- Keep validation pure and derive errors from values on each render instead of storing them, because a stored error can outlive the input that produced it.
Common mistakes
- Typing errors as a record from string to message, which compiles for any spelling and lets a mistyped key become an error no field can display.
Caveats
- Errors derived during render describe the current values, so a message can change while the reader is typing; reveal-on-blur keeps that from moving text under the caret.
- The DOM name attribute is runtime data. The checked mapping is what keeps an unrelated control inside the form from writing to a field it does not name.
Prerequisite
Prerequisite
Validate a controlled React form on submitThe field rules are the same in the untyped version, so read that before adding the types.
Related examples
Editorial links first, then deterministic same-task or same-topic candidates. Tags and shared language alone never qualify a candidate.
Related
Style a visible keyboard focus ring with custom propertiesStyle the focus ring on the field that is reporting an error so both describe the same control.
Same task or topic
Validate a controlled React form on submitSolves the Validate form input task
Same task or topic
Announce field errors without moving the caretSolves the Validate form input task
References
- Utility Types(opens in a new tab) — Microsoft (TypeScript handbook). Documents Partial and Record, which combine into the per-field error and touched maps used here.
- useState(opens in a new tab) — React Team. Covers the updater form used to change one field without rebuilding the values object by hand.
- aria-invalid(opens in a new tab) — MDN (Mozilla). Describes how the invalid state and a described error message are exposed to assistive technology.
Source page: https://devexamples.com/typescript/type-a-controlled-form-validation-state/