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.

8 min read

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.

typescripttypescript
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:

typescripttypescript
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:

typescripttypescript
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:

typescripttypescript
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.

typescripttypescript
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:

typescripttypescript
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:

typescripttypescript
  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:

typescripttypescript
const elements = getFormElements();

Attach the submit listener to elements.form, and pass elements into readFormValues so every field stays fully typed inside the handler:

typescripttypescript
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.

typescripttypescript
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.

typescripttypescript
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.

Type safe form data flow

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

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.
RunePowered by Rune AI

Frequently Asked Questions

How do I structure a typed form in TypeScript?

Define an interface for your form data shape. Use querySelector with generics to get typed form fields, read values with the correct property (.value, .checked, .valueAsNumber), validate at runtime, and collect the data into an object matching your interface before submitting.

Should I use FormData or read fields individually?

Read fields individually when you need type-safe values like numbers and booleans. FormData gives you everything as strings, so you lose type information. Use FormData only when you are sending raw string data to a server and do not need typed values in your client code.

How do I handle form validation errors with types?

Define a type for validation errors, such as a Record with field names as keys and error messages as values. Collect errors in a typed object as you validate each field. Display them next to the relevant fields using the same field names.

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.