Build a Type Safe Browser Form
Learn how to build a fully typed HTML form in TypeScript that reads, validates, and submits user input with compile-time safety from start to finish.
A type-safe form is a regular HTML form where every field, every value read, and every validation error is backed by a TypeScript type. The compiler catches structural mistakes like reading .value from the wrong element type, and your validation code catches input mistakes like an empty name field.
This article walks through building a complete typed signup form from scratch. You will define a data interface, get typed references to form fields, read values with the correct property, validate input at runtime, and submit the result to an API.
Define the data shape
Start with a TypeScript interface that describes the data your form collects. This becomes the contract that the rest of your code works against.
interface SignupData {
name: string;
email: string;
age: number;
agreedToTerms: boolean;
}Each field has a clear type matching its HTML input. Name and email are strings from text inputs. Age is a number from a number input. agreedToTerms is a boolean from a checkbox.
This interface drives every subsequent step: selecting elements, reading values, validating, and submitting.
Get typed references to form fields
Use querySelector with generics to get typed references to each input. The generic tells TypeScript which element type to expect, and each result starts as the element type or null.
A null check only narrows a variable within the scope where the check happens. If you look up the fields at the top level but read them inside separate functions later, TypeScript forgets the check and still treats each variable as possibly null. Wrap the lookups and the guard in one function that returns a typed object instead:
interface FormElements {
form: HTMLFormElement;
nameInput: HTMLInputElement;
emailInput: HTMLInputElement;
ageInput: HTMLInputElement;
agreeCheckbox: HTMLInputElement;
}FormElements describes every field with no null in sight, because by the time you have one of these objects, the lookup already succeeded. The function below is the only place that does the null checking:
function getFormElements(): FormElements {
const form = document.querySelector<HTMLFormElement>('#signup');
const nameInput = document.querySelector<HTMLInputElement>('#name');
const emailInput = document.querySelector<HTMLInputElement>('#email');
const ageInput = document.querySelector<HTMLInputElement>('#age');
const agreeCheckbox = document.querySelector<HTMLInputElement>('#agree');
if (!form || !nameInput || !emailInput || !ageInput || !agreeCheckbox) {
throw new Error('Form elements are missing from the page');
}
return { form, nameInput, emailInput, ageInput, agreeCheckbox };
}getFormElements returns FormElements, not FormElements or null, because the throw statement stops execution before the return line can run with a missing element. Every function that needs a form field takes an elements: FormElements parameter instead of reading outer variables directly.
Read values with the correct property
Different input types need different properties. Read text inputs with .value, number inputs with .valueAsNumber, and checkboxes with .checked.
Here is a function that reads each field correctly and returns a typed object:
function readFormValues(elements: FormElements) {
const name = elements.nameInput.value.trim();
const email = elements.emailInput.value.trim();
// ^? const name: string, const email: string
const age = elements.ageInput.valueAsNumber;
const agreedToTerms = elements.agreeCheckbox.checked;
// ^? const age: number, const agreedToTerms: boolean
return { name, email, age, agreedToTerms };
}The comment annotations show the inferred types for each variable. Name and email are both strings, age resolves to a number through valueAsNumber, and agreedToTerms is a boolean from checked.
The return statement builds a SignupData-compatible object. Passing elements in as a parameter, instead of reading nameInput and the others as outer variables, is what keeps this function free of null checks.
Validate at runtime
TypeScript cannot check that a string is a valid email or that a number is positive. You validate these at runtime and collect errors in a typed object. Start by defining the error shape with optional fields so you only include errors for fields that actually failed.
interface SignupErrors {
name?: string;
email?: string;
age?: string;
agreedToTerms?: string;
}Each field in the errors object mirrors a field in the data interface. An optional string means the field is either missing (no error) or present with a message. Now write the validation function that checks each field:
function validate(data: SignupData): SignupErrors {
const errors: SignupErrors = {};
if (data.name === '') errors.name = 'Name is required';
if (!data.email.includes('@')) errors.email = 'Please enter a valid email';
if (Number.isNaN(data.age) || data.age < 13) errors.age = 'You must be at least 13';
return errors;
}The function returns an errors object with only the fields that failed. The agreedToTerms check is handled separately because it involves a boolean, not a string:
if (!data.agreedToTerms) {
errors.agreedToTerms = 'You must agree to the terms';
}An empty errors object means the form is valid and all fields passed validation.
Wire up the submit handler
The submit event handler connects everything together: read the values, validate them, show errors or submit the data. Call getFormElements once, then reuse the result everywhere:
const elements = getFormElements();Attach the submit listener to elements.form, and pass elements into readFormValues so every field stays fully typed inside the handler:
elements.form.addEventListener('submit', (event) => {
event.preventDefault();
const data = readFormValues(elements);
const errors = validate(data);
if (Object.keys(errors).length > 0) {
displayErrors(errors);
return;
}
submitData(data);
});The flow is linear. Prevent the default form submission, read typed values, validate, and either show errors or submit. The data variable has type SignupData because readFormValues returns an object matching that interface. Calling getFormElements once and reusing the result keeps every field fully typed everywhere it is used.
Display validation errors
Connect the typed errors object to the DOM by showing messages next to each field. Use the same field names from the SignupErrors interface to keep the mapping consistent.
function displayErrors(errors: SignupErrors): void {
const nameError = document.querySelector('#name-error');
const emailError = document.querySelector('#email-error');
const ageError = document.querySelector('#age-error');
const agreeError = document.querySelector('#agree-error');
if (nameError) nameError.textContent = errors.name ?? '';
if (emailError) emailError.textContent = errors.email ?? '';
if (ageError) ageError.textContent = errors.age ?? '';
if (agreeError) agreeError.textContent = errors.agreedToTerms ?? '';
}The nullish coalescing operator gives an empty string when no error exists for a field. This clears the previous message when the user corrects that field.
Submit the typed data
The final step sends the validated SignupData object to your API. The fetch call uses POST with a JSON body, and the error handling checks both network failures and HTTP error status codes.
async function submitData(data: SignupData): Promise<void> {
const response = await fetch('https://api.example.com/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Server error: ${response.status}`);
}
console.log('Signup successful');
}JSON.stringify accepts the typed SignupData object directly. The server receives a correctly shaped JSON body.
Use try-catch at the handler level to catch network errors and the HTTP error from the response.ok check. For a typed server response, add a response type as shown in the fetch article.
The full form flow
The complete type-safe form moves data through several stages, each with its own type checking.
At each stage, the type system ensures correct data shapes. HTML elements become typed variables through querySelector with generics. Raw values become a SignupData object through the correct value properties.
Validation produces a SignupErrors object that mirrors the data shape. The final submission sends the typed data to the server as JSON.
See Type Form Elements in TypeScript for individual form field typing. See Type Input Values in TypeScript for the value properties of each input type.
Rune AI
Key Insights
- Define a TypeScript interface that describes the shape of your form data.
- Use querySelector with generics to get precisely typed references to each form field.
- Read values with the correct property: .value for text, .checked for checkboxes, .valueAsNumber for numbers.
- Validate at runtime and collect errors in a typed errors object.
- Build the final data object matching your interface and submit it as JSON.
Frequently Asked Questions
How do I structure a typed form in TypeScript?
Should I use FormData or read fields individually?
How do I handle form validation errors with types?
Conclusion
A type-safe browser form connects TypeScript interfaces to DOM elements through typed selectors and dedicated value properties. Define your data shape first, get typed references to each field, read values correctly, validate at runtime, and submit the typed object. The type system catches structural mistakes, and your validation catches input mistakes.
More in this topic
TypeScript Compiler Explained
The TypeScript compiler (tsc) turns .ts files into .js files while checking types. Learn what it does, how to use it, and how it fits your workflow.
Set Up ESLint for TypeScript
Install and configure ESLint with typescript-eslint to catch bugs and enforce consistent code style in your TypeScript project.
Pick Utility Type in TypeScript
Pick creates a new type by selecting only the properties you name from an existing type. Use it to build focused types for specific views, API responses, or function parameters.