How to make an Astro form type-safe
The key to a type-safe form is not forcing FormData to trust your types. It is treating request data as untrusted, converting it through one validation function, and only then passing the result to the rest of the application.
This approach gives each layer a clear responsibility:
| Layer | Responsibility | Example |
|---|---|---|
| Browser | Give the user early feedback | required, type="email" |
| Parser | Check the values that actually arrived | typeof, type predicates |
| TypeScript | Enforce consistency during development | as const, satisfies, unions |
| Astro endpoint | Receive requests and return predictable states | 422, error codes |
Where FormData loses its types
When a visitor submits a form, form.get('email') does not always return a string. Its return type is FormDataEntryValue | null, meaning string | File | null, based on what was actually sent under that field name.
The type="email" attribute gives the visitor a useful first check, but it does not change the type received by the endpoint. A person, bot, or another client can always send a request without using your form page.
This code therefore hides risk:
const email = form.get('email') as string;
as string silences TypeScript without proving that the value is a string. A misspelled field name, a missing value, or a file sent in its place moves the failure from compile time to runtime.
Types describe what the code believes. Validation proves what the data is. A safe form needs both.
FormDataEntryValue | nullThat means string | File | null — the sender need not have used your formform.get('email') as string- Compile time
- Told to believe it is a string
- Runtime
- Can still be a File or null
- Result
- Fails later, far from the cause
typeof value === 'string'- Compile time
- Narrows to string on its own
- Runtime
- Actually verified before use
- Result
- Bad input refused at the edge, with an error code
- A string nobody verified
- A checked ContactInput, or a 422
Declare the types and allowed values once
Start with a plain module that is independent of Astro, so the page, endpoint, and tests use the same source of truth.
// src/lib/feedback.ts
export const TOPICS = ['bug', 'question', 'other'] as const;
export type Topic = (typeof TOPICS)[number];
export type FeedbackInput = {
name: string;
email: string;
topic: Topic;
message: string;
};
export const TOPIC_LABELS = {
bug: 'Report a problem',
question: 'Ask a question',
other: 'Something else',
} satisfies Record<Topic, string>;
as const keeps TOPICS as a literal union instead of widening it to string[]. satisfies then checks that TOPIC_LABELS has a label for every topic without unnecessarily widening the object itself.
The page can iterate over TOPICS to render its options and use TOPIC_LABELS[topic] for the visible label. When a topic is added, TypeScript points to both the form and any other incomplete consumer.
Parse FormData and validate at runtime
The same module should expose a parser that accepts the raw values and returns a discriminated union. Invalid form input is an expected result, so it does not need to be thrown as a system exception.
// src/lib/feedback.ts (continued)
type FieldError = 'required' | 'invalid' | 'too-short';
export type ParseResult =
| { ok: true; value: FeedbackInput }
| { ok: false; errors: Partial<Record<keyof FeedbackInput, FieldError>> };
function isTopic(value: string): value is Topic {
return TOPICS.some((topic) => topic === value);
}
function text(form: FormData, field: string): string {
const raw = form.get(field);
return typeof raw === 'string' ? raw.trim() : '';
}
export function parseFeedback(form: FormData): ParseResult {
const name = text(form, 'name');
const email = text(form, 'email');
const topic = text(form, 'topic');
const message = text(form, 'message');
const errors: Partial<Record<keyof FeedbackInput, FieldError>> = {};
if (name.length === 0) errors.name = 'required';
if (!email.includes('@')) errors.email = 'invalid';
if (!isTopic(topic)) errors.topic = 'invalid';
if (message.length < 10) errors.message = 'too-short';
if (!isTopic(topic) || Object.keys(errors).length > 0) {
return { ok: false, errors };
}
return { ok: true, value: { name, email, topic, message } };
}
The text() helper closes off the File and null cases before calling .trim(). The isTopic() type predicate validates the runtime value while narrowing topic from string to Topic for TypeScript.
The includes('@') email check is deliberately illustrative. A production system should apply the validation policy appropriate to its use case, or introduce a schema-validation library as the form becomes more complex.
Connect the Astro endpoint correctly
Once the parser owns validation, the endpoint only has to receive the request, call the parser, and return an HTTP status.
// src/pages/api/feedback.ts
import type { APIRoute } from 'astro';
import { parseFeedback } from '../../lib/feedback';
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
const result = parseFeedback(await request.formData());
if (!result.ok) {
return Response.json({ errors: result.errors }, { status: 422 });
}
// Persist result.value, which is now a validated FeedbackInput.
return new Response(null, { status: 204 });
};
If the project uses Astro’s default static mode, this route needs both export const prerender = false and an Astro adapter for the target runtime. Setting prerender alone does not add a server runtime to the project.
If a Worker or separate backend receives the form, you do not need this Astro API route. The same boundary still applies: validate the request before using it, and share the type or parser wherever both runtimes support it.
Return error codes for the page to translate
Keep the error response shape stable so the page can associate each problem with a field without guessing.
{
"errors": {
"email": "invalid",
"message": "too-short"
}
}
Return stable codes instead of finished sentences, then translate them in the presentation layer. This lets UX copy change without affecting the API and gives tests a clear contract to assert.
The visible message should explain both the problem and the recovery. “Enter a complete email address, such as name@example.com” is more useful than “Invalid value.” Associate the message with its field using aria-describedby so assistive technology announces it.
Let browser validation help first
Server-side validation is the trusted boundary. Browser validation helps visitors correct input sooner and avoids unnecessary requests. Start with the standard attributes before adding JavaScript:
requiredfor fields that must be completedtype="email"andtype="url"for basic format checksminlengthandmaxlengthfor text lengthpatternfor a specific format, with the same rule enforced on the server
Do not rely on color alone to communicate an error. Provide text, a visible focus state, and a way to move to the first field that needs attention. See MDN’s guide to client-side form validation for the browser behavior.
A practical implementation order
- Declare allowed values, types, and label maps in one module.
- Render page options from those values instead of repeating strings.
- Write a parser that checks
null,File, formats, and business constraints. - Let the endpoint accept only the value returned when
okistrue. - Return stable error codes and translate them in the presentation layer.
- Test valid input, missing fields, unknown allowed values, and files sent to text fields.
Frequently asked questions
Do I still need server validation when I use TypeScript?
Yes. TypeScript works during development and cannot guarantee values coming from a browser, bot, or API client. Request data remains untrusted until a runtime parser validates it.
Do I need Zod or another schema library?
Not necessarily for a small form with straightforward rules. A library becomes useful for reducing repetition and handling nested data, transformations, or complex constraints. Either way, runtime validation at the system boundary is the important part.
Can a static Astro site receive a POST request?
Prebuilt HTML cannot process a request body on its own. To use an Astro API route, install an adapter and mark the route for on-demand rendering with prerender = false. Alternatively, submit the form to a Worker or external backend.
Why not use as string when it is shorter?
Because as string performs no runtime check and can hide null or File until a later failure. A parser makes the raw-data boundary visible, testable, and able to return only validated data.

