Validation Across Client and Server
Learning Objectives
- You can distinguish immediate form feedback from authoritative API validation.
- You can convert form strings into deliberate wire values.
- You can map server errors back to controls without trusting the browser’s checks.
A form can prevent some obvious mistakes before a request is sent. That does not replace the server schema. The same API can be called by another browser, a script, or an old client that uses different rules.
Continue the notes page and API from Chapter 4.3. We need to decide which representation is validated, where, and why. A brief capacity example also shows what changes when a form field must become a number.
Use the practice application (practice-typed-http/) for the neutral forms, conversion helpers, API examples, and validation tests in this chapter.
Validation in the browser, API, and database
A numeric text field begins as text. In Part 3’s room example, the API requires an integer capacity and the database stores it under its own constraints. The notes API supplied for this part has only a title; capacity is a separate conversion example, not another endpoint to implement here.
An HTML required or min attribute provides feedback through the browser. A Svelte function can add cross-field feedback. The API still parses and validates the actual request. Database constraints remain the last line of defense for stored invariants.
Figure 1 separates local conversion from authoritative decisions. A raw caller can skip the browser entirely, so the API always validates again.
Which boundary decides availability?
0 / 5 points
Two users see one remaining workshop seat. Both browsers pass their local form checks. Where must acceptance be decided?
Convert form strings with Zod
The starter already includes Zod in client/package.json, with its pinned
version recorded in client/deno.lock. The API has its own dependency declaration
in api/deno.json. These are separate applications, even when both use Zod.
No dependency installation or configuration change is needed for this chapter.
A form field begins as a string; the room API expects a number. Reject blank text before conversion so it cannot silently become zero. A schema for this controlled form value can reuse the API’s capacity range:
import { z } from "zod";
const CapacityTextSchema = z.string().trim().min(1) .pipe(z.coerce.number().int().min(1).max(500));
const parsed = CapacityTextSchema.safeParse(" 4 ");if (parsed.success) { console.log(parsed.data); // the number 4, ready for the JSON proposal}Keep the original control text for correction. The server independently validates the resulting JSON; accepting this conversion cannot reserve a room or establish current availability.
Other fields need their own conversion rules. A decimal amount may need conversion to integer cents. The registration exercise supplies its amount schema so the task can focus on using parsed values and preserving feedback.
Shared schemas and server validation
Schema reuse can reduce drift, but importing the same schema into both projects does not make a browser check authoritative. It executes twice in different environments. Conversely, a client need not download every server schema to offer useful feedback. It can check simple constraints and always show authoritative server errors.
Typed clients introduced next share type information. They do not run the server’s schema on the browser’s behalf. A TypeScript number can still be negative, fractional, outside a range, or no longer acceptable under current state.
Validation after a browser check
0 / 5 points
A title must be nonblank and an amount must be a bounded integer. The form already checks both. Which server design is appropriate?
Parse a complete form value
The capacity example isolates conversion. For the note form from Chapter 4.3,
create client/src/lib/schemas/note-schema.ts:
import { z } from "zod";
export const NoteDraftSchema = z.object({ title: z.string().trim().min(1, "Enter a title").max(120, "Use at most 120 characters"),});The input control keeps the raw text while the schema produces the trimmed proposal. The same distinction applies to the exercise’s participants and fee fields: its supplied schema converts those strings, and the component uses the parsed output.
Return errors a form can use
A useful error response has a stable code and optional field details:
{ "error": { "code": "INVALID_INPUT", "message": "Check the submitted proposal", "fields": [{ "path": "title", "message": "Enter a title" }] }}Associate known field errors with the corresponding controls. Put unfamiliar or operation-wide errors in a summary. Never inject a server message as raw HTML. Display it as text and do not expose database details or stack traces.
Extend Chapter 4.3’s note-api.ts so the documented error can carry fields.
Change its error type and constructor to these definitions:
type FieldError = { path: string; message: string };type ErrorResponse = { error: { code: string; message: string; fields?: FieldError[] } };
export class ApiError extends Error { constructor( public status: number, public code: string, message: string, public fields: FieldError[] = [], ) { super(message); }}In createNote’s non-201 branch, pass error?.fields ?? [] as the fourth
ApiError argument. The unreadable-JSON branch keeps its existing three arguments.
Existing status/code/message tests should still pass.
Create client/src/lib/components/ValidatedNoteForm.svelte. This version keeps
local validation and server feedback in the same submission path:
<script lang="ts"> import { ApiError, type Note } from "#lib/api/note-api.ts"; import { NoteDraftSchema } from "#lib/schemas/note-schema.ts";
let { saveNote, onaccepted = () => {} }: { saveNote: (title: string) => Promise<Note>; onaccepted?: (note: Note) => void; } = $props(); let titleError = $state(""); let message = $state(""); let pending = $state(false);
const submit = async (event: SubmitEvent & { currentTarget: HTMLFormElement }) => { event.preventDefault(); if (pending) return; const data = new FormData(event.currentTarget); const parsed = NoteDraftSchema.safeParse({ title: data.get("title") }); titleError = ""; message = ""; if (!parsed.success) { titleError = parsed.error.issues[0].message; message = "Check the title."; return; }
pending = true; message = "Submitting note..."; try { const accepted = await saveNote(parsed.data.title); onaccepted(accepted); message = `Accepted: ${accepted.title}`; } catch (error) { if (error instanceof ApiError) { titleError = error.fields.find((field) => field.path === "title")?.message ?? ""; const otherMessages = error.fields .filter((field) => field.path !== "title") .map((field) => field.message); message = [error.message, ...otherMessages].join(" "); } else { message = "The note could not be confirmed."; } } finally { pending = false; } };</script>
<form onsubmit={submit} novalidate> <label for="validated-title">Title</label> <input id="validated-title" name="title" oninput={() => { titleError = ""; }} aria-invalid={Boolean(titleError)} aria-describedby={titleError ? "note-title-error" : undefined} /> {#if titleError}<p id="note-title-error">{titleError}</p>{/if} <button type="submit" disabled={pending}>Submit note</button> <p role="status">{message}</p></form>novalidate lets this example consistently show its schema feedback. Known field messages appear beside the input;
unrecognized paths remain visible in the summary. The component passes parsed
text to the operation and preserves the user’s original control text on rejection.
Editing clears the field’s obsolete invalid state.
In NotesPanel.svelte, replace NoteForm with ValidatedNoteForm in the import
and markup, preserving saveNote, initialNotes, and onaccepted. Keep /lab’s
real API connection. Submit whitespace: local feedback should appear without a
POST in Network. Then submit a title already shown in the list. It passes the
local shape check, but the API returns 409 TITLE_EXISTS; the form shows
Choose a different title beside the input. Neither rejection adds a row.
Finally submit a new title and reload to confirm it was stored.
Test feedback and preserved input
Continue the existing notes suite. The API-module and parent-list checks from Chapter 4.3 remain useful; add this form-level check for the new responsibility: turning a validation result into feedback beside the retained input. Keep this file for the pending-state case in Chapter 4.8.
Save ValidatedNoteForm.test.ts beside the component:
import { cleanup, render, screen } from "@testing-library/svelte";import { userEvent } from "@testing-library/user-event";import { afterEach, expect, test, vi } from "vitest";import { ApiError } from "#lib/api/note-api.ts";import ValidatedNoteForm from "./ValidatedNoteForm.svelte";
afterEach(cleanup);
test("submits parsed text and retains the original after server rejection", async () => { const user = userEvent.setup(); const saveNote = vi.fn().mockRejectedValue(new ApiError(400, "INVALID_INPUT", "Check the note", [ { path: "title", message: "That title is unavailable" }, { path: "collection", message: "The collection is closed" }, ])); render(ValidatedNoteForm, { props: { saveNote } }); await user.type(screen.getByLabelText("Title"), " Maps "); await user.click(screen.getByRole("button", { name: "Submit note" })); expect(await screen.findByText("That title is unavailable")).toBeInTheDocument(); expect(saveNote).toHaveBeenCalledWith("Maps"); expect(screen.getByLabelText("Title")).toHaveValue(" Maps "); expect(screen.getByLabelText("Title")).toHaveAccessibleDescription("That title is unavailable"); expect(screen.getByRole("status")).toHaveTextContent("The collection is closed");});Run the Chapter 4.3 client-test command from practice-typed-http/. This case
controls the operation to observe field feedback and retained input. The direct
API test below separately exercises server validation.
The registration exercise transfers the same flow to several fields. Its operation
returns {ok:false,error} for a documented rejection instead of throwing ApiError.
The starter supplies that result type. Inside the form’s try block, after a
successful RegistrationSchema.safeParse, the core distinction is:
const result = await submitRegistration({ participants: parsed.data.participants, feeCents: parsed.data.fee, discountCode: parsed.data.discountCode,});if (result.ok) { message = "Registration accepted.";} else { message = result.error.message; // Also map result.error.fields to the known controls or the summary.}An expected rejection enters else; a rejected promise enters catch. Keep the
pending flag and finally cleanup used by the note form. The supplied schema
owns decimal conversion, so use its parsed values while preserving raw strings.
For several fields, inspect each issue’s path rather than always displaying the
first issue beside a single title input.
The registration exercise supplies its form, raw values, local validation, and wire conversion. Repair the operation feedback and pending transitions, keeping the user’s proposal available after rejection. You do not need to build another form or reproduce its supplied conversion rules.
Repair registration feedback
0 / 30 points
Files in the editor
The editor below contains all 3 files for this exercise, although only one opens initially. Use the Files panel to expand the folders and select a filename. If the panel is collapsed, choose Show file tree in the editor toolbar.
- Edit
lib/components/Registration.svelte. - Keep unchanged
lib/schemas/registration-schema.ts. - Keep unchanged
routes/+page.svelte.
The component imports the complete RegistrationSchema from ../schemas/registration-schema.ts. The page supplies the submitRegistration prop.
The grader supplies the Svelte 5/SvelteKit application, Zod, and browser checks.
Editor paths are relative to the local client’s src/ directory; keep the
shorter paths shown here in the editor.
The supplied page calls /__exercise/ endpoints controlled by the grader
to vary submitRegistration outcomes. These endpoints are available during grading;
you do not need to implement them or find their code in another file.
Task
Repair the supplied lib/components/Registration.svelte. It already renders
labelled controls, keeps their raw strings in raw, validates them with the
complete RegistrationSchema, and constructs {participants,feeCents,discountCode}.
Keep this local-validation and conversion behavior; do not build another form
or change the supplied schema/page.
Complete the operation feedback in the submit handler:
- Enter pending state before calling submitRegistration and clear it after
either outcome. The supplied guard, disabled button and
Submitting...status use that state to prevent another submission while waiting. - On
{ok:false,error:{message,fields?}}, preserve every raw string. Associate messages for participants, fee and discountCode with their existing controls by updatingerrors; the supplied markup connects each message and marks the control invalid. Include unfamiliar field messages in the status summary together with the operation message. Render messages as text. - On a rejected promise, preserve the proposal and show
Registration could not be confirmed.; a missing response proves no rollback. Keep the suppliedRegistration accepted.success feedback and clear stale field errors on the next submission.
The fixture accepts code OPEN and rejects other codes; even OPEN can receive a separate operation failure. These controlled outcomes exercise the UI, not a real server’s validation. No local application or authored test suite is needed.
Server field feedback is worth 15 points; operation/unfamiliar-field feedback is worth 5 points, and pending/uncertain outcomes are worth 10 points. Supplied local validation and conversion remain checked as unscored baseline behavior.
Check and submit
Choose Submit solution below the editor to run the grading checks and read the feedback. All editor files, including unchanged files and files you have not opened, are submitted together. There is no separate Run preview. Grading does not require a local application. Keep file paths and exported names as specified; additional observations belong in your own notes.
Read the named checks to identify the feedback behavior to revise; baseline checks award no points.
Test the API without the form
Add api/tests/note-form-bypass_test.ts in the practice application. Use the
same notes router with a repository that must never receive invalid input:
import { assertEquals } from "@std/assert";import { createNotesApp } from "@src/routes/notes-routes.ts";
Deno.test("the notes API rejects a caller that bypasses the form", async () => { const app = createNotesApp({ list: async () => [], create: async () => { throw new Error("Invalid input reached persistence"); }, }); const response = await app.request("/api/notes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: " ", approved: true }), }); assertEquals(response.status, 400); assertEquals((await response.json()).error.code, "INVALID_INPUT");});From practice-typed-http/, run:
docker compose run --build --rm --no-deps api deno test --cached-only tests/note-form-bypass_test.tsThe request reaches the real validator through app.request() without a browser
or listener. This focused rejection needs no database fixture; Chapter 4.3’s
supplied integration test separately checks stored records and duplicate titles.
A valid shape also does not establish permission. A caller could submit a plausible
ownerId that belongs to someone else. The server must obtain identity from its
trusted authentication mechanism and compare that identity with stored ownership.
Part 3’s local development actor is only a testing aid; Part 5 implements real
sessions and permission checks. Keep that distinction when answering the next question.
An editable field and an actor
0 / 5 points
A JSON edit contains a valid title and ownerId: 17. Which fact should authorize the edit?
Convert a local deadline to an API timestamp
A datetime-local input supplies text such as 2027-06-14T18:30, with no time zone.
For the marketplace, interpret that choice in the browser’s local time zone and
send a UTC ISO timestamp.
In the submit handler, read the named control with FormData. Reject an empty
string, then construct const deadline = new Date(localDeadline). If
Number.isNaN(deadline.getTime()), show input feedback instead of publishing.
Otherwise, send deadline.toISOString() as endsAt. The API still checks that
this instant is a valid future deadline.
Date uses the browser’s local zone for this input. The ISO value ends in Z;
its clock time may differ from the selected local time because it represents the
same instant in UTC. Do not append Z to the input text: that would interpret
the selected clock time as UTC instead.
Try the conversion
For a local rehearsal, create client/src/lib/components/DeadlinePreview.svelte:
<script lang="ts"> let message = $state("");
const preview = (event: SubmitEvent & { currentTarget: HTMLFormElement }) => { event.preventDefault(); const localDeadline = String(new FormData(event.currentTarget).get("deadline") ?? ""); if (!localDeadline) { message = "Choose a local date and time."; return; } const deadline = new Date(localDeadline); if (Number.isNaN(deadline.getTime())) { message = "Choose a valid date and time."; return; } const zone = Intl.DateTimeFormat().resolvedOptions().timeZone; message = `${localDeadline} in ${zone} becomes ${deadline.toISOString()}`; };</script>
<form onsubmit={preview} novalidate> <label for="deadline-preview">Deadline (your local time)</label> <input id="deadline-preview" name="deadline" type="datetime-local" /> <button type="submit">Preview deadline</button> <p role="status">{message}</p></form>Mount it on a separate client/src/routes/lab/deadline/+page.svelte:
<script lang="ts"> import DeadlinePreview from "#lib/components/DeadlinePreview.svelte";</script>
<h1>Deadline conversion</h1><DeadlinePreview />At /lab/deadline, try an empty value, then choose a date and time. Compare the
selected local time, the browser’s zone, and the resulting UTC timestamp. This
preview demonstrates conversion; it does not publish anything.
Daylight-saving changes
Client-side work uses browser’s local zone, and server-side work uses the server’s local zone. Around daylight-saving changes, a local time can occur twice or not occur
at all. Date chooses the earlier instant for a repeated time and moves a
nonexistent time forward by the gap. The result can therefore be a valid date
without matching the clock time the user intended. This small preview follows
that behavior; an interface that needs an explicit choice must show the resolved
local time and zone for confirmation. See the Date reference.
Check Your Understanding
- Why must the API check both submitted values and current availability even when client validation passes?
- Why can a converted
number, such as zero from empty input, still be an invalid monetary amount? - Why should a server error be mapped to a known field or summary rather than parsed from arbitrary English text?