LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
TypeScriptIntermediate~2 hours

Form Validator

Model validation results as a discriminated union of success and error variants.

Discriminated UnionsType GuardsEnums

Overview

Form validation in plain JavaScript usually ends up as an array of error strings, or a boolean plus a separately-tracked message — two pieces of information that have to be kept in sync by hand, with nothing stopping them from disagreeing. This project models a validation outcome as a single discriminated union instead: a result is either { valid: true } or { valid: false; errors: string[] }, and it is structurally impossible to end up "valid" with leftover error messages, or "invalid" with no messages to show.

On top of the union, this project introduces two more TypeScript features: a real enum for the form's field names (rather than plain strings), and hand-written type guard functions — isValid and hasErrors — whose return type (result is { valid: true }) tells the compiler exactly how to narrow a ValidationResult after the guard runs.

What You'll Build
  • A FormField enum naming every field in the form, instead of scattering raw string literals through the code.
  • A ValidationResult discriminated union representing either a passing or a failing validation, never both at once.
  • Per-field validator functions for email, password, and confirm-password, each returning a ValidationResult.
  • Type guard functions (isValid, hasErrors) that narrow a ValidationResult for the compiler, not just for a human reader.
  • A live form that revalidates on every keystroke and shows or hides field-level error messages accordingly.

Prerequisites

  • Interfaces and union types from an earlier TypeScript project — this project builds directly on the union-narrowing ideas from the Typed Task Manager project.
  • Regular expressions well enough to read (not necessarily write from scratch) a simple email-format check.
  • DOM basics — reading .value from inputs on the input event, and toggling classes/text content to show validation feedback.
  • What an enum is conceptually: a named set of related constants, distinct from a plain union of string literals.

Project Structure

The project is one validator.ts file plus a small sign-up form in index.html. Every validation rule lives in a small, pure function — a function with no DOM code in it, that takes a string and returns a ValidationResult — so the validation logic itself can be tested (or reused on a server) completely independent of how it happens to be displayed in this particular form.

The DOM-facing code at the bottom of the file is intentionally thin: on every input event, it re-runs validation and calls one renderFieldErrors function per field. Nothing else in the app is allowed to write error text into the page directly — that keeps the "what does the user see" logic in exactly one place.

<!-- Root container for the sign-up form -->
<div class="validator-app">
<h1>Create Account</h1>
<form id="signup-form" novalidate>
<div class="field">
<label for="email-input">Email</label>
<input type="text" id="email-input" autocomplete="off" />
<ul class="errors" id="email-errors"></ul>
</div>
<div class="field">
<label for="password-input">Password</label>
<input type="password" id="password-input" autocomplete="off" />
<ul class="errors" id="password-errors"></ul>
</div>
<div class="field">
<label for="confirm-input">Confirm Password</label>
<input type="password" id="confirm-input" autocomplete="off" />
<ul class="errors" id="confirm-errors"></ul>
</div>
<button type="submit" id="submit-btn" disabled>Create Account</button>
</form>
</div>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: Arial, Helvetica, sans-serif;
background: #1a1d23;
color: #e8e8e8;
display: flex;
justify-content: center;
padding: 40px 16px;
}
.validator-app {
width: 100%;
max-width: 420px;
background: #23262e;
border-radius: 10px;
padding: 24px;
}
.validator-app h1 { margin-bottom: 16px; font-size: 1.5rem; }
.field { margin-bottom: 16px; }
.field label {
display: block;
margin-bottom: 6px;
font-size: 0.9rem;
color: #9aa4b2;
}
.field input {
width: 100%;
padding: 10px 12px;
border-radius: 6px;
border: 1px solid #3a3f4b;
background: #1a1d23;
color: #e8e8e8;
font-size: 1rem;
}
.field input.invalid { border-color: #ff6b6b; }
.errors {
list-style: none;
margin-top: 6px;
font-size: 0.8rem;
color: #ff6b6b;
}
#submit-btn {
width: 100%;
padding: 10px 16px;
border: none;
border-radius: 6px;
background: #4f8cff;
color: white;
cursor: pointer;
font-weight: bold;
}
#submit-btn:disabled {
background: #3a3f4b;
color: #6b7280;
cursor: not-allowed;
}

Step 1: Define Fields with an Enum

A FormField enum names each field once, so the rest of the code refers to FormField.Email instead of the raw string 'email' scattered everywhere. Unlike a union of string literals (which fully disappears at compile time), an enum also produces a small real object at runtime, which is what lets Object.values(FormField) be used later to loop over every field.

// A string enum: each member has an explicit string value, so
// FormField.Email is both a type (for annotations) and a real runtime value
// (for use as an object key or in a loop) — a plain union of literals only
// gives you the first half of that
enum FormField {
Email = 'email',
Password = 'password',
ConfirmPassword = 'confirmPassword',
}

A plain union type (type FormField = 'email' | 'password' | 'confirmPassword') would have worked for type-checking alone, since Step 5 needs to iterate over every field at runtime, an enum is the better fit here — it is a set of related constants that genuinely need to exist as values, not just as a compile-time-only annotation.

Step 2: Model Results with a Discriminated Union

This is the type the whole project is built around. The valid field is the discriminant: when it is true, TypeScript knows there is no errors field to worry about; when it is false, TypeScript requires errors to exist. There is no third state where both are set, or neither is — the type itself rules that out.

// A discriminated union: the "valid" field's literal value (true vs false)
// determines which other fields exist on the object. TypeScript narrows on
// this field automatically wherever it's checked
type ValidationResult =
| { valid: true }
| { valid: false; errors: string[] };

Step 3: Write Field Validators

Each validator is a small, pure function: given a string, it returns a ValidationResult. None of them touch the DOM, so each one can be tested (or reused) completely on its own.

const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function validateEmail(value: string): ValidationResult {
const errors: string[] = [];
if (value.trim() === '') {
errors.push('Email is required.');
} else if (!EMAIL_PATTERN.test(value)) {
errors.push('Enter a valid email address.');
}
// Building this as the very last step keeps the "valid" object literally
// impossible to also carry an errors field — the return type enforces it
return errors.length === 0 ? { valid: true } : { valid: false, errors: errors };
}
function validatePassword(value: string): ValidationResult {
const errors: string[] = [];
if (value.length < 8) {
errors.push('Password must be at least 8 characters.');
}
if (!/[0-9]/.test(value)) {
errors.push('Password must include at least one number.');
}
return errors.length === 0 ? { valid: true } : { valid: false, errors: errors };
}
function validateConfirmPassword(password: string, confirm: string): ValidationResult {
return password === confirm
? { valid: true }
: { valid: false, errors: ['Passwords do not match.'] };
}

Step 4: Write Type Guards

A type guard is a function whose return type is a special form — result is { valid: true } — instead of a plain boolean. It still returns an actual true/false at runtime like any other check, but its *type* tells the compiler: everywhere this function returns true, treat the argument as narrowed to that specific branch of the union.

// The "result is { valid: true }" return type is what makes this a type
// guard rather than a plain boolean-returning function. Anywhere
// isValid(result) is checked in an if/while/filter, TypeScript narrows
// "result" from ValidationResult down to just the { valid: true } branch
function isValid(result: ValidationResult): result is { valid: true } {
return result.valid;
}
// The mirror-image guard, narrowing to the branch guaranteed to carry
// "errors" — used by renderFieldErrors in Step 6 to safely read result.errors
function hasErrors(result: ValidationResult): result is { valid: false; errors: string[] } {
return !result.valid;
}
What the Type Guard Buys You

Click Run to see what this code prints.

Step 5: Validate the Whole Form

Record<FormField, string> and Record<FormField, ValidationResult> guarantee — at compile time — that a value exists for every single enum member, with no field forgotten. isFormValid reuses the isValid type guard from Step 4 directly as the predicate passed to .every().

// Record<FormField, string> means "an object with exactly one string value
// per FormField member" — leaving one out, or adding an extra key, is a
// compile-time error
function validateForm(values: Record<FormField, string>): Record<FormField, ValidationResult> {
return {
[FormField.Email]: validateEmail(values[FormField.Email]),
[FormField.Password]: validatePassword(values[FormField.Password]),
[FormField.ConfirmPassword]: validateConfirmPassword(
values[FormField.Password],
values[FormField.ConfirmPassword],
),
};
}
// Reuses isValid directly as the predicate for .every() — no wrapper
// arrow function needed, since isValid's signature already matches what
// .every() expects
function isFormValid(results: Record<FormField, ValidationResult>): boolean {
return Object.values(results).every(isValid);
}

Step 6: Wire Up Reactive DOM Feedback

Every input's input event re-runs validateForm on the current values, calls renderFieldErrors for each field, and enables the submit button only once isFormValid returns true — the compile-time guarantees from the union and type guards now drive real, live UI behavior.

const emailInput = document.getElementById('email-input') as HTMLInputElement;
const passwordInput = document.getElementById('password-input') as HTMLInputElement;
const confirmInput = document.getElementById('confirm-input') as HTMLInputElement;
const submitBtn = document.getElementById('submit-btn') as HTMLButtonElement;
const errorLists: Record<FormField, HTMLUListElement> = {
[FormField.Email]: document.getElementById('email-errors') as HTMLUListElement,
[FormField.Password]: document.getElementById('password-errors') as HTMLUListElement,
[FormField.ConfirmPassword]: document.getElementById('confirm-errors') as HTMLUListElement,
};
const inputs: Record<FormField, HTMLInputElement> = {
[FormField.Email]: emailInput,
[FormField.Password]: passwordInput,
[FormField.ConfirmPassword]: confirmInput,
};
function renderFieldErrors(field: FormField, result: ValidationResult): void {
const list = errorLists[field];
const input = inputs[field];
list.innerHTML = '';
input.classList.toggle('invalid', hasErrors(result)); // reuses the Step 4 type guard as a plain condition too
if (hasErrors(result)) {
// Narrowed to { valid: false; errors: string[] } inside this block —
// result.errors is safe to iterate with no cast
result.errors.forEach(function (message: string) {
const li = document.createElement('li');
li.textContent = message;
list.appendChild(li);
});
}
}
function revalidate(): void {
const values: Record<FormField, string> = {
[FormField.Email]: emailInput.value,
[FormField.Password]: passwordInput.value,
[FormField.ConfirmPassword]: confirmInput.value,
};
const results = validateForm(values);
renderFieldErrors(FormField.Email, results[FormField.Email]);
renderFieldErrors(FormField.Password, results[FormField.Password]);
renderFieldErrors(FormField.ConfirmPassword, results[FormField.ConfirmPassword]);
submitBtn.disabled = !isFormValid(results);
}
[emailInput, passwordInput, confirmInput].forEach(function (input: HTMLInputElement) {
input.addEventListener('input', revalidate);
});

Complete Code

Here is the complete .ts source. Note that the interactive demo below shows the type-erased JavaScript equivalent — the enum, ValidationResult union, and is-typed guard functions all exist only at compile time and produce no runtime trace of their own except the small object enum compiles down to.

<div class="validator-app">
<h1>Create Account</h1>
<form id="signup-form" novalidate>
<div class="field">
<label for="email-input">Email</label>
<input type="text" id="email-input" autocomplete="off" />
<ul class="errors" id="email-errors"></ul>
</div>
<div class="field">
<label for="password-input">Password</label>
<input type="password" id="password-input" autocomplete="off" />
<ul class="errors" id="password-errors"></ul>
</div>
<div class="field">
<label for="confirm-input">Confirm Password</label>
<input type="password" id="confirm-input" autocomplete="off" />
<ul class="errors" id="confirm-errors"></ul>
</div>
<button type="submit" id="submit-btn" disabled>Create Account</button>
</form>
</div>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: Arial, Helvetica, sans-serif;
background: #1a1d23;
color: #e8e8e8;
display: flex;
justify-content: center;
padding: 40px 16px;
}
.validator-app {
width: 100%;
max-width: 420px;
background: #23262e;
border-radius: 10px;
padding: 24px;
}
.validator-app h1 { margin-bottom: 16px; font-size: 1.5rem; }
.field { margin-bottom: 16px; }
.field label {
display: block;
margin-bottom: 6px;
font-size: 0.9rem;
color: #9aa4b2;
}
.field input {
width: 100%;
padding: 10px 12px;
border-radius: 6px;
border: 1px solid #3a3f4b;
background: #1a1d23;
color: #e8e8e8;
font-size: 1rem;
}
.field input.invalid { border-color: #ff6b6b; }
.errors { list-style: none; margin-top: 6px; font-size: 0.8rem; color: #ff6b6b; }
#submit-btn {
width: 100%;
padding: 10px 16px;
border: none;
border-radius: 6px;
background: #4f8cff;
color: white;
cursor: pointer;
font-weight: bold;
}
#submit-btn:disabled { background: #3a3f4b; color: #6b7280; cursor: not-allowed; }
enum FormField {
Email = 'email',
Password = 'password',
ConfirmPassword = 'confirmPassword',
}
type ValidationResult =
| { valid: true }
| { valid: false; errors: string[] };
function isValid(result: ValidationResult): result is { valid: true } {
return result.valid;
}
function hasErrors(result: ValidationResult): result is { valid: false; errors: string[] } {
return !result.valid;
}
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function validateEmail(value: string): ValidationResult {
const errors: string[] = [];
if (value.trim() === '') {
errors.push('Email is required.');
} else if (!EMAIL_PATTERN.test(value)) {
errors.push('Enter a valid email address.');
}
return errors.length === 0 ? { valid: true } : { valid: false, errors: errors };
}
function validatePassword(value: string): ValidationResult {
const errors: string[] = [];
if (value.length < 8) {
errors.push('Password must be at least 8 characters.');
}
if (!/[0-9]/.test(value)) {
errors.push('Password must include at least one number.');
}
return errors.length === 0 ? { valid: true } : { valid: false, errors: errors };
}
function validateConfirmPassword(password: string, confirm: string): ValidationResult {
return password === confirm
? { valid: true }
: { valid: false, errors: ['Passwords do not match.'] };
}
function validateForm(values: Record<FormField, string>): Record<FormField, ValidationResult> {
return {
[FormField.Email]: validateEmail(values[FormField.Email]),
[FormField.Password]: validatePassword(values[FormField.Password]),
[FormField.ConfirmPassword]: validateConfirmPassword(
values[FormField.Password],
values[FormField.ConfirmPassword],
),
};
}
function isFormValid(results: Record<FormField, ValidationResult>): boolean {
return Object.values(results).every(isValid);
}
const emailInput = document.getElementById('email-input') as HTMLInputElement;
const passwordInput = document.getElementById('password-input') as HTMLInputElement;
const confirmInput = document.getElementById('confirm-input') as HTMLInputElement;
const submitBtn = document.getElementById('submit-btn') as HTMLButtonElement;
const errorLists: Record<FormField, HTMLUListElement> = {
[FormField.Email]: document.getElementById('email-errors') as HTMLUListElement,
[FormField.Password]: document.getElementById('password-errors') as HTMLUListElement,
[FormField.ConfirmPassword]: document.getElementById('confirm-errors') as HTMLUListElement,
};
const inputs: Record<FormField, HTMLInputElement> = {
[FormField.Email]: emailInput,
[FormField.Password]: passwordInput,
[FormField.ConfirmPassword]: confirmInput,
};
function renderFieldErrors(field: FormField, result: ValidationResult): void {
const list = errorLists[field];
const input = inputs[field];
list.innerHTML = '';
input.classList.toggle('invalid', hasErrors(result));
if (hasErrors(result)) {
result.errors.forEach(function (message: string) {
const li = document.createElement('li');
li.textContent = message;
list.appendChild(li);
});
}
}
function revalidate(): void {
const values: Record<FormField, string> = {
[FormField.Email]: emailInput.value,
[FormField.Password]: passwordInput.value,
[FormField.ConfirmPassword]: confirmInput.value,
};
const results = validateForm(values);
renderFieldErrors(FormField.Email, results[FormField.Email]);
renderFieldErrors(FormField.Password, results[FormField.Password]);
renderFieldErrors(FormField.ConfirmPassword, results[FormField.ConfirmPassword]);
submitBtn.disabled = !isFormValid(results);
}
[emailInput, passwordInput, confirmInput].forEach(function (input: HTMLInputElement) {
input.addEventListener('input', revalidate);
});
Live Preview

Sample Run

Sample Interaction

Click Run to see what this code prints.

Extend This Project

  • Add a FormField.Username member and its validator, following the same Record<FormField, ...> pattern so nothing else needs restructuring.
  • Add an async validateEmailUnique(email: string): Promise<ValidationResult> that reuses the fetchJson<T> pattern from the Typed API Client project to check availability against a server.
  • Debounce revalidate on the email field specifically, so an async uniqueness check (from the previous idea) doesn't fire on every keystroke.
  • Add a third union variant, { valid: false; errors: string[]; warnings: string[] }, for non-blocking suggestions like "consider a longer password."
  • Extract a generic ValidationResult<E = string> so error messages could be typed objects (with an error code) instead of plain strings, for i18n.

Summary

You modeled validation as a discriminated union that makes an invalid "valid" result impossible to construct, and wrote real type guard functions whose return type actively narrows the union for the compiler rather than just describing intent in a comment. Combined with the enum for field names and Record<FormField, T> for guaranteeing every field is handled, this project is a template for representing "this succeeded" vs. "this failed, here's why" anywhere in a TypeScript codebase — not just in forms.