Inside a Next.js Server Action declared as async function createUser(formData: FormData), what does formData.get('email') actually return, and why can that value not be passed straight into your database call?
answer
- get() is not typed for you
- string, File, or null
- annotations vanish at runtime
- the body is whatever was posted
- parse first, then use
basics
~20 sformData.get('email') returns FormDataEntryValue | null — a string, a File, or null if the field was absent. The FormData type annotation is erased at runtime and the body is whatever the caller posted, so the action must parse and validate before using the value.
solid answer
~50 s`FormData.get()` returns `FormDataEntryValue | null`, which is `string | File | null`. So three things can be true even though the parameter is typed `FormData`: the field may be missing entirely and give you `null`, it may be a `File` rather than a string, and if it is a string it can be any string of any length. The TypeScript annotation is compile-time only — it is erased, and the action is a POST endpoint, so the body is exactly what the caller chose to send, not what your form renders. The fix is to parse the whole payload into a typed shape at the top of the action with a schema validator, reject on failure by returning a structured error the form can display, and pass only the validated object onward. Never rely on `required`, `maxlength`, or `type="email"` on the input: those are UX, not validation.
go deeper
Know that get() hands back string | File | null and that HTML attributes like required are not validation. Say that you check for absence and parse the value before using it.
Explain why the TypeScript annotation is erased and why the incoming body is not constrained to the fields your form rendered, then show a schema parse producing a typed object at the top of the action.
Show that you treat validation as an allowlist that drops unknown keys, bound sizes and lengths deliberately, and return structured field errors instead of throwing so the form stays usable.
Be ready to argue for one shared schema as the contract across client, action, and persistence, and to say how you stop validation logic from drifting or becoming its own attack surface across many teams.
## What the API actually gives you `FormData` is a standard Web API type, and its accessors are deliberately loose: - `get(name)` returns `FormDataEntryValue | null`, i.e. `string | File | null`. - `getAll(name)` returns an array, because a name may appear many times (checkbox groups, multi-selects, or a caller who simply repeated the field). - `has(name)` tells you presence, not type. So `formData.get('email')` is `null` when the key is absent, a `File` when the client sent a file part under that name, and otherwise a string with no length, format, or charset guarantee. ```ts const raw = formData.get('email') // string | File | null const email = String(raw ?? '') // still unvalidated ``` ## Why the type annotation proves nothing Two separate points, and interviewers want both. **Types are erased.** `async function createUser(formData: FormData)` is checked at compile time between *your* modules. At runtime there is no residual check; nothing inspects the incoming body against the annotation. This is true of any TypeScript signature, and it is exactly why validation libraries exist. **The caller is not your form.** A Server Action is a POST endpoint. Your rendered `<form>` is one client among many. Someone can send extra fields, omit required ones, send `role=admin` when your UI never draws that input, send a 40 MB string, or send an array where you expected a scalar. Nothing in the framework filters the body down to "the fields the form declared" — there is no server-side record of what your JSX rendered. ## What good looks like Parse once, at the top, into a typed value: ```ts 'use server' import { z } from 'zod' const CreateUser = z.object({ email: z.string().email().max(254), displayName: z.string().min(1).max(80), }) export async function createUser(formData: FormData) { const parsed = CreateUser.safeParse(Object.fromEntries(formData)) if (!parsed.success) { return { ok: false, errors: parsed.error.flatten().fieldErrors } } // parsed.data is now typed AND checked } ``` The shape matters as much as the library. Note three properties: 1. **Allowlist, not cleanup.** The schema names the fields you accept; `Object.fromEntries` plus a strict object schema means unexpected keys are dropped rather than forwarded into an ORM call that might happily set them. 2. **Fail closed and return, don't throw.** A validation failure is an expected, user-fixable outcome. Returning a serializable object lets the form render field-level messages through the React state hook driving it; throwing turns a typo into an error boundary. 3. **Bounds are part of validation.** Maximum lengths matter both for storage and because the action body is a place unbounded strings become expensive work. ## Files need their own treatment If the field is genuinely a file, `get()` gives you a `File`, and its `name`, `type`, and `size` are all caller-supplied. `type` is the browser's claim, not a sniffed content type, and `name` may contain path separators. Check `size` against your own limit, decide the extension yourself, and never join the supplied name onto a filesystem path. Note also that Next caps the action request body — the limit is configurable through the `serverActions` options in `next.config`, and the default is small precisely so an unbounded upload does not arrive by accident. ## The client-side attributes are not validation `required`, `pattern`, `maxlength`, `type="email"`, and a disabled submit button all improve the experience for a cooperating browser and are worth keeping. None of them affect what arrives at the endpoint. Say this explicitly in an interview — it is the single most common junior gap on this question. ## Coercion is part of the job HTML form values are strings even when the domain type is not. A checkbox that is unchecked sends nothing at all rather than `false`; a number input sends `"42"`; a date input sends an ISO-ish string. So the validated schema is also where you coerce, and the absence of a checkbox key has to be interpreted rather than treated as a missing required field. ## Summary sentence to say out loud "`get()` hands me `string | File | null`, the annotation is erased, and the body came from an untrusted caller — so the first statement in the action parses the whole payload into a typed object, and everything after that works with the parsed value."
- Your form has a checkbox named 'newsletter'. What arrives in FormData when the user leaves it unchecked?Nothing. Unchecked checkboxes are not submitted, so `formData.get('newsletter')` is `null`, and `has('newsletter')` is false. That means absence has to mean `false` in your schema rather than "required field missing". The mirror-image trap is that a checked box sends the literal string `"on"` unless you set an explicit `value`.
- How should a validation failure surface in the UI?Return a serializable result object from the action — an ok flag plus per-field messages — rather than throwing. The form component reads it through the React hook that holds the action's returned state and renders inline messages. Throwing escalates a routine typo to an error boundary and loses the user's input.
- Is validating on the server enough, or do you still validate on the client?Server validation is the only one that is a control; client validation is a latency and UX optimisation. I keep both, ideally from one shared schema so they cannot drift, but the server never trusts the client's verdict — it re-runs the full parse on every call.
saying these in an interview costs you the question
- The parameter is typed FormData, so the fields are typed
- required and maxlength on the input already validate it
- get() always returns a string
- Only fields my form renders can appear in the body
- Validation is fine on the client since the action is server-side