LearnAI ToolsCareerPractice BuildsPlayContact
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.