TypeScript in practice

Runtime validation and type safety

Types are erased, so safety comes from validating at boundaries — schemas with zod, valibot and ArkType under Standard Schema, deriving types from schemas, sharing them between Nuxt server and client, branded types, exhaustive checks, typed errors and Result types.

"TypeScript makes it type-safe" is the sentence interviewers wait for so they can ask "at runtime too?". Types disappear at compile time; anything that enters the program from outside, HTTP bodies, query strings, environment variables, storage, third-party APIs, is unknown until something checks it. The senior pattern is schemas at the boundary that both validate and define the types.

Know

  • Boundaries are where data enters: request bodies and params on the server, API responses on the client, localStorage/cookies, environment variables, webhooks, messages from workers or sockets. Inside the boundary, types are trustworthy because the boundary enforced them.
  • Schema libraries: zod (v4, the default in the Vue ecosystem; z.object, z.infer, parse/safeParse, transforms, refinements, z.input vs z.output), valibot (modular and tree-shakable, ideal for client bundles), ArkType (TypeScript-syntax strings, very fast). All implement Standard Schema, a common interface that Nuxt UI forms, VeeValidate, h3 (readValidatedBody), TanStack and others accept, so the choice is per project, not per library integration.
  • Derive, do not duplicate: type Product = z.infer<typeof productSchema>; the schema is the single source of truth and the type follows. Schemas live in shared/schemas so the Nuxt server route and the page import the same one.
  • Where to validate in Nuxt: readValidatedBody(event, schema.parse), getValidatedQuery, getValidatedRouterParams on the server (throw 400 with issues); useRuntimeConfig values at startup in a Nitro plugin; API responses on the client only when the API is not yours (your own routes are typed end to end); form input through the form library.
  • safeParse versus parse: parse throws a ZodError; safeParse returns { success, data | error } for flows where failure is expected (user input). Format issues for users with flatten()/treeifyError and for logs with the raw issue list.
  • Branded types encode invariants the structural system cannot: UserId, Email, NonEmptyString; schemas produce them (z.string().email().brand<'Email'>()), so a validated value carries proof of validation in its type.
  • Exhaustiveness with never in switch defaults and satisfies Record<Union, ...> for lookup tables, so adding a variant is a compile error everywhere it matters.
  • Errors are unknown. catch (e) is unknown under useUnknownInCatchVariables; narrow with instanceof, a type guard, or Error.isError (ES2026). Model expected failures as data (Result<T, E> discriminated unions) and reserve exceptions for the unexpected; FetchError from ofetch carries status, statusText and typed data.
  • Environment and config: parse process.env/runtimeConfig once at startup with a schema and fail fast; never as string an env variable.
  • Cost: validation is not free on hot paths; validate at the edge once, not on every function call inside. Valibot's modular design and ArkType's speed matter when schemas run in the browser for every keystroke.

How it works

The boundary pattern end to end in Nuxt:

shared/schemas/product.ts
import { z } from 'zod'

export const productSchema = z.object({
  id: z.string().uuid().brand<'ProductId'>(),
  name: z.string().trim().min(1).max(120),
  status: z.enum(['draft', 'published', 'archived']),
  priceMinor: z.number().int().nonnegative(),
  tags: z.array(z.string()).max(10).default([]),
})
export type Product = z.output<typeof productSchema>
export type ProductId = Product['id']

export const createProductSchema = productSchema.omit({ id: true })
export type CreateProductInput = z.input<typeof createProductSchema> // tags optional on input, required on output
server/api/products.post.ts
import { createProductSchema } from '#shared/schemas/product'

export default defineEventHandler(async (event) => {
  const input = await readValidatedBody(event, createProductSchema.parse) // 400 with issues in `data` on failure
  return useDb().products.create(input) // input is typed and *validated*; no cast anywhere
})
server/plugins/config.ts
import { z } from 'zod'

const envSchema = z.object({
  stripeSecret: z.string().min(1),
  session: z.object({ maxAge: z.number().int().positive() }),
})

export default defineNitroPlugin(() => {
  // Fail fast at startup instead of discovering a missing secret on the first checkout.
  envSchema.parse(useRuntimeConfig())
})

Result types and typed errors on the client, plus a third-party API validated because it is not ours:

app/utils/result.ts
export type Result<T, E = { status: number, message: string }> = { ok: true, value: T } | { ok: false, error: E }

export async function tryFetch<T>(schema: { parse: (v: unknown) => T }, url: string): Promise<Result<T>> {
  try {
    const raw: unknown = await $fetch(url)
    return { ok: true, value: schema.parse(raw) } // validated: the third party changed a field? we learn here, not in a template
  }
  catch (e) {
    if (Error.isError(e) && 'status' in e) return { ok: false, error: { status: Number(e.status ?? 500), message: e.message } }
    return { ok: false, error: { status: 500, message: 'Unexpected error' } }
  }
}

Exhaustive lookup tables that fail to compile when a variant is added:

app/utils/status.ts
import type { Product } from '#shared/schemas/product'

export const STATUS_LABEL = {
  draft: 'Draft',
  published: 'Live',
  archived: 'Archived',
} satisfies Record<Product['status'], string> // add 'scheduled' to the schema and this line errors
Gotcha· Validating what you already trust

Re-parsing your own typed /api response on the client wastes CPU and hides the real seam. Your own routes are typed end to end by Nuxt; validate their inputs on the server and validate external sources on whichever side receives them.

Gotcha· `as` after a fetch

const p = (await $fetch(url)) as Product compiles and proves nothing; when the upstream renames a field, the failure surfaces as undefined in a template three components later. A parse fails at the boundary with the field name in the error.

Verify before the interview:

The current zod major and its brand/treeifyError API names, and the Standard Schema adopter list (Nuxt UI, VeeValidate, h3) as of the interview week.

docs ↗

Exercise

Exercise
  • Build the product schema, the POST route and a page that calls it; send an invalid body with curl and read the 400 payload, then break the schema on purpose to see the page's compile error.
  • Add the startup config validation and start the server without NUXT_STRIPE_SECRET; confirm it refuses to start with a clear message.
  • Replace one as cast on external data in a project with safeParse and log what the first real failure reveals.

Be able to say

Be able to say· Types are erased at runtime. How do you make a Nuxt app actually type-safe?

"By validating at the boundaries and deriving types from the validators. A schema in shared/schemas, zod or valibot, defines each input once; the server route parses bodies and params with readValidatedBody, so invalid input becomes a 400 with field-level issues, and the handler's return type flows to useFetch through Nuxt's generated route types, so my own API is typed end to end without casts. External data, third-party APIs, storage, environment variables, gets parsed where it enters, and config is validated once at startup so a missing secret fails the boot rather than the first checkout. Inside those boundaries the types are trustworthy, and I keep them honest with unknown for anything unchecked, branded types for validated ids, and exhaustive switches so new variants cannot be forgotten."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.