Appearance
Tier 2a · Chapter 1: Type-Safe Fullstack Boundaries
JS/TS Fullstack (Nuxt) Track · ~30 min read + exercise
Why this chapter exists
The Foundations track gave you the architectural vocabulary; this track makes it concrete in a Nuxt fullstack application — Vue on the client, Nitro on the server, TypeScript across both. We start with boundaries, because in a fullstack app you have more of them than in a backend service, and they're the places architecture most often quietly breaks. There's the trust boundary where external data enters your Nitro server (the classic backend concern), and there's a second, subtler boundary that a backend-only app never has: the server/client boundary, where data flows from your server to your Vue components, often serialized through SSR and rehydrated in the browser. Both are boundaries; both need types that are enforced, not merely annotated; and Nuxt's great advantage — the same language and the same types on both sides — is only an advantage if you actually use it to make those boundaries safe.
The trap is the same one TypeScript developers always fall into, now in two places. TypeScript gives you types, so it feels like your boundaries are safe — but a type is a claim, and the compiler can't see across the network, the request body, the database row, or the SSR serialization. At the Nitro server's edge, a request body typed with a cast is a lie waiting to crash deep in your handler. And across the server/client boundary, a type shared between them is only trustworthy if the data actually survives the trip — Nuxt serializes the SSR payload and rehydrates it on the client, and anything that doesn't round-trip cleanly (most often a class instance, which comes back as a plain object without its methods) becomes a bug that the shared type hides rather than catches.
So the subject of this chapter is making both boundaries real: validating untrusted input at the Nitro server edge, and using Nuxt's shared-type capability to keep the server/client contract honest — with awareness of what serialization does to it. The architecture you designed only holds if the boundaries are walls, and in a fullstack app there are two walls to build.
What this chapter covers
- The two boundaries — the server trust boundary (external → server) and the server/client boundary (server → browser).
- Validating at the Nitro edge — parse, don't assert, for everything entering the server.
- Shared types across the stack — Nuxt's killer feature, and the serialization caveat that makes it tricky.
- Modeling so illegal states can't compile — discriminated unions and branded types, on both sides.
The two boundaries
A backend service has one trust boundary worth obsessing over: the edge where external data arrives. A Nuxt fullstack app has that one plus the server/client boundary, and conflating them or ignoring either is where fullstack type safety breaks.
The server trust boundary is the familiar one. Data arriving at a Nitro server route — request bodies, query params, route params, headers, and crucially the responses from any external API your server calls — is unknown no matter what type you write. Inside your own server code, TypeScript's guarantees hold; at this edge, a type annotation is a claim about data that originated somewhere the compiler never saw. This is the same trust boundary from any backend, and it gets the same treatment: validate, don't assert.
The server/client boundary is the fullstack-specific one, and it has a wrinkle. When Nuxt renders a page on the server (SSR), the data your server fetched is serialized to JSON, embedded in the HTML, sent to the browser, and rehydrated into your Vue app so the client picks up where the server left off without re-fetching. That round trip is a boundary the type system thinks is safe — you used the same type on both sides — but the wire can still change your data. The one failure to internalize: a class instance does not survive. It comes back as a plain object carrying its data but none of its methods or prototype, so the shared type still says Ticket, but ticket.canResolve() throws is not a function in the browser, far from the server code that "typed" it correctly. (The good news is that Nuxt serializes the payload with devalue, not plain JSON.stringify, so the rich types that trip up naive JSON — Date, Map, Set, RegExp, even Vue refs — do round-trip faithfully; only custom or exotic types need a registered reducer/reviver, and a plain-JSON boundary like a raw third-party response degrades further, e.g. a Date to a string.)
The architectural point: both boundaries need verification, but they fail differently. The server trust boundary fails because external data doesn't match your claim — defend it with validation. The server/client boundary fails because serialization can drop what the type system assumes survives — defend it by keeping cross-boundary data round-trip-safe (plain data, not behavior — and validating it on arrival if it came from anywhere untrusted). Recognizing that a Nuxt app has two distinct boundaries with two distinct failure modes is the foundation everything else in this chapter builds on.
Validating at the Nitro edge
Every Nitro server route is a trust boundary, so every route validates its input. Nitro gives you the request data as unknown-shaped helpers (readBody, getQuery, getRouterParams) — and h3 ships validated variants (readValidatedBody, getValidatedQuery, getValidatedRouterParams) that parse that data against a schema, narrowing unknown to a typed value or throwing, instead of casting it. Define the schema once, in shared/, so the route and the form can both use it:
typescript
// shared/ticket.ts — one schema, imported by the route AND the Vue form
import * as v from "valibot"; // Zod is a drop-in alternative
export const CreateTicket = v.object({
customerId: v.pipe(v.string(), v.uuid()),
subject: v.pipe(v.string(), v.minLength(1), v.maxLength(200)),
priority: v.optional(v.picklist(["low", "normal", "high"]), "normal"),
});
export type CreateTicket = v.InferOutput<typeof CreateTicket>;typescript
// server/api/tickets.post.ts — a Nitro server route, validated at the edge
import { CreateTicket } from "~/shared/ticket";
export default defineEventHandler(async (event) => {
// readValidatedBody parses the unknown body against the schema:
// success → a typed CreateTicket; failure → h3 throws a clean 400.
const input = await readValidatedBody(event, CreateTicket);
// From here the input is trusted and typed. No casts, no downstream re-checking.
return createTicket(input);
});The architectural points carry from any backend trust boundary, with Nitro specifics:
Validate the whole input surface — body, query, route params, headers — since all of it is client-controlled. Nitro's helpers each return untrusted data; each needs validating if you act on it (readValidatedBody, getValidatedQuery, getValidatedRouterParams). We validate with a shared schema — Valibot here (Zod is interchangeable; h3 accepts any Standard-Schema library directly). The architectural payoff is that one schema is the contract on both sides of the app: the Nitro edge parses with it, and the Vue form drives the same schema through vee-validate (which consumes Standard-Schema / Valibot / Zod schemas, e.g. via toTypedSchema). One definition, enforced identically at the form and at the server, with nothing hidden and no second source to drift — the form keeps the validation it always had, now shared with the trust boundary too.
Validate external API responses too, not just inbound requests. When your Nitro server calls a third-party API, that response is as untrusted as a request body — the external service can change its shape, return an error envelope you didn't expect, or send null where you assumed an object. Parsing external responses at the point they enter your server is the same boundary discipline, and it's the thing most often skipped, producing bugs that look like your code failing when it's actually the upstream's shape that changed.
A validation failure is a clean 4xx, not a crash. Malformed input is expected; the route should respond with a structured error (Nitro's createError with a 400/422 and a useful message), which the client can handle, rather than throwing an unhandled error that becomes a 500. This is the trust boundary doing its job — bad input stopped cleanly at the edge.
The result is the foundations' "validate at the edge, trust the interior" pattern in Nitro: your server's business logic receives only valid, typed values, and the messy reality of untrusted input is handled at the perimeter.
Shared types across the stack
Here is Nuxt's genuine superpower for architecture, and the reason fullstack TypeScript is more than two separate codebases that happen to share a language: the client and server can share the same types, so the contract between them is checked by one compiler across the whole stack. A backend service and a separate frontend each have their own idea of the API contract, and they drift; a Nuxt app can define the contract once and have both sides type-checked against it, eliminating a whole class of integration bug at compile time.
You get this enforced two ways. For responses, Nuxt infers the return type of your server routes and flows it to the client automatically — when a route returns a TicketResponse, the useFetch consuming it is typed TicketResponse with no manual annotation, and changing the route's return shape lights up every client call site. For inputs, the shared schema from the previous section is the contract: it validates the body at the edge, drives the form via vee-validate, and its TypeScript type is inferred from the schema itself (v.InferOutput), so there's no separate hand-written input type to drift. Either way it's one definition, one compiler, enforced end to end — the schema validates the values, and the inferred types pin the shape the compiler checks:
typescript
// shared/ticket.ts (continued) — the response half of the contract
export interface TicketResponse { id: string; status: "open" | "resolved"; subject: string }
// The input half is the CreateTicket schema above; its type is v.InferOutput<typeof CreateTicket>.
// On the client, useFetch infers the response type from the server route — no manual typing.
const { data } = await useFetch("/api/tickets/123"); // data is typed TicketResponse | nullBut — and this is the caveat that separates a working fullstack app from a subtly broken one — the shared type tells the truth only if the data survives the round trip. As the boundaries section covered, Nuxt's payload uses devalue, so most rich types (Date, Map, Set, refs) come back intact — but a class instance loses its methods, custom types need a reducer/reviver, and a plain-JSON boundary degrades more (a Date to a string). So the discipline for cross-boundary types:
Send plain data across the boundary, not behavior. The reliable rule isn't "everything must be a JSON primitive" — devalue handles dates and collections — it's don't expect methods or class identity to survive. If the server works with a rich Ticket entity, expose a plain TicketResponse shape across the boundary and reconstruct any behavior on the client from that data (or register a reducer/reviver only if a custom type genuinely must round-trip). It's the same "transform at the edge" instinct as validation: hand the client a shape, not an object graph that assumes its prototype came along.
Treat hydrated data as needing the same honesty as any boundary. The rehydrated client value is data that crossed a serialization boundary; if its provenance is untrusted (it ultimately came from a user or external source), the same validation logic applies. More commonly, the issue is simply type-vs-reality drift from serialization, which you prevent by keeping the shared types serialization-honest in the first place.
Used well, shared types are the strongest boundary guarantee in the whole course — one contract, one compiler, both sides — provided you respect what the wire does to your data.
Modeling so illegal states can't compile
The "make illegal states unrepresentable" discipline applies on both sides of a Nuxt app, and it's worth applying deliberately because fullstack code has more state to model — server domain state and client UI state.
On the server (and in shared types), model domain concepts as discriminated unions so impossible combinations don't compile — the resolved-ticket-with-no-resolution problem from any TypeScript domain:
typescript
type Ticket =
| { status: "open"; subject: string }
| { status: "resolved"; subject: string; resolution: string };
// { status: "resolved" } without a resolution is a compile error.On the client, the same instinct tames UI state, which is where Vue apps accumulate bugs. A component fetching data has states — loading, loaded, errored — and the weak modeling (isLoading: boolean, data: T | null, error: Error | null) allows nonsense combinations: loading and errored, loaded with null data. Modeled as a discriminated union, the impossible UI states stop compiling:
typescript
type FetchState<T> =
| { status: "loading" }
| { status: "error"; error: string }
| { status: "success"; data: T };
// Can't be loading-and-errored; can't be success-with-no-data.This makes Vue templates safer too — narrowing on state.status gives the template a guaranteed shape per branch, so v-if="state.status === 'success'" blocks can access state.data knowing it exists. Branded types also earn their place across the stack: a TicketId that can't be passed where a CustomerId is expected, even though both are strings, prevents a class of mix-up that's easy to make when IDs flow from server to client to other calls.
The throughline of the chapter: a Nuxt app has two boundaries — the server trust boundary and the server/client boundary — and type safety means making both real. Validate untrusted input at the Nitro edge, share one type-checked contract across the stack while keeping it serialization-honest, and model illegal states away on both the server and the client. The types are your boundaries; in a fullstack app there are two of them, and a type is only a wall if something verifies it.
Practice
Budget about ninety minutes.
Exercise 1: Map your two boundaries (20 min)
In a Nuxt app you work on, identify both boundaries. At the server edge: find a Nitro route that reads input (body/query/params) and check whether it validates or casts. At the server/client boundary: find data fetched on the server and rendered on the client, and check whether any of it is a Date, Map, or class instance that serialization would mangle. List what you find on each boundary.
Exercise 2: Validate a Nitro route (35 min)
Pick a server route that casts or trusts its input and convert it to validate against a shared schema via readValidatedBody (Valibot or Zod), returning a clean 4xx on failure. If your app has a form for the same data, point vee-validate at that same schema so one definition guards both sides. If the route calls an external API, validate that response too. Note how much downstream defensive code you could delete once the input is guaranteed.
Exercise 3: Fix a serialization-leaky type (35 min)
Find a type that crosses the server/client boundary and isn't round-trip-honest (a class instance whose methods you call on the client, say). Make it honest — expose a plain data shape across the boundary and reconstruct any behavior on the client from it — and confirm the client receives what the type promises. Optionally (instead of or in addition to the serialization fix), model one piece of client UI state (a fetch's loading/error/success) as a discriminated union, and note which impossible states it eliminated.
Deliverable
A short before/after covering both boundaries: one Nitro route moved from cast to parsed validation, and one cross-boundary type made serialization-honest (or one client UI state modeled as a discriminated union) — with a sentence on the failure each change now prevents, and which boundary it protects.
Check yourself
- Name the two boundaries in a Nuxt app and the distinct way each one fails.
- Explain why a cast on
readBodyis unsafe and what parsing does instead. - Explain why a shared type can still lie across SSR, and how to keep cross-boundary types honest.
- Model a client fetch's UI state as a discriminated union and explain what becomes uncompilable.
What a strong answer demonstrates: a strong answer treats both boundaries as distinct — defending the Nitro edge by parsing untrusted input (not casting) and keeping the server/client contract serialization-honest (plain data, not behavior across the wire). The clearest signal of understanding is naming a concrete failure each change now prevents — a crash from a lying cast, a method-less rehydrated object — and showing one piece of state modeled so an impossible combination simply won't compile.
Going deeper (optional)
- Node.js Design Patterns (Casciaro & Mammino) — the module-system and structural chapters inform how to organize shared code across server and client.
- The Valibot / Zod docs and h3's data-validation utilities (
readValidatedBody,getValidatedQuery), thevee-validatedocs (and its Standard-Schema /toTypedSchemasupport for driving a form from the same schema). (Flagged: Nuxt/Nitro behavior — server-route type inference, SSR/devalue serialization — and the validation libraries are current-ecosystem knowledge, not from the course's book set, which predates these tools.) - Primary source: Alexis King, "Parse, Don't Validate" (blog post, 2019) — the original, sharp statement of the principle behind narrowing untrusted
unknownto a verified type at the boundary, which is exactly what validating at the Nitro edge does. It's the theory on-ramp for this whole chapter; short and worth reading directly.
Key takeaways
- A Nuxt app has two boundaries: the server trust boundary (external → Nitro) and the server/client boundary (server → browser via SSR). Both need enforced types, and they fail differently.
- At the Nitro edge, parse untrusted input (body, query, params, and external API responses) — don't cast — and return clean 4xx on failure.
- Shared types across the stack are Nuxt's superpower — one contract, one compiler, both sides — but only if cross-boundary data survives the round trip (Nuxt's devalue payload preserves
Date/Map/Set/refs, but a class instance loses its methods; send plain data, not behavior). - Make illegal states unrepresentable on both sides: discriminated unions for server domain state and client UI state (loading/error/success), plus branded types for IDs that flow across the stack.
Next: The Nuxt Rendering & Execution Model — where your code actually runs (server, client, or both), SSR/CSR/hydration, and the architectural implications of a runtime that spans two environments.