DEV Community

Damiano
Damiano

Posted on Originally published at damianobianda.hashnode.dev

Your TypeScript types disappear at runtime: validating data at the edges of a Nuxt app

TypeScript checks your code before it runs. Once it ships, the types are gone. The compiler cannot tell you what a third-party API actually returned at 3 AM on a Sunday.

I've written full-stack TypeScript with Nuxt and Vue for about six years. The production bugs I remember most were rarely logic errors. Most came from one assumption: "the data has the shape I typed it as."

The examples below are small and made up for this article. They are not taken from a specific project.

The problem: a type assertion is a promise, not a check

// server/api/user/[id].get.ts
interface User {
  id: number
  email: string
  plan: 'free' | 'pro'
}

export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, 'id')
  const user = await $fetch(`https://api.example.com/users/${id}`) as User
  return { label: user.email.toLowerCase(), plan: user.plan }
})
Enter fullscreen mode Exit fullscreen mode

This compiles. If the API one day returns email: null for accounts created through a different sign-up flow, user.email.toLowerCase() throws. The as User told the compiler to stop asking questions. It checked nothing.

Fix: parse at the boundary with a schema

A schema library such as Zod keeps the type and the runtime check in one place:

import { z } from 'zod'

const UserSchema = z.object({
  id: z.number(),
  email: z.string().email().nullable(),
  plan: z.enum(['free', 'pro']),
})
type User = z.infer<typeof UserSchema>

export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, 'id')
  const raw = await $fetch(`https://api.example.com/users/${id}`)
  const parsed = UserSchema.safeParse(raw)
  if (!parsed.success) {
    throw createError({
      statusCode: 502,
      statusMessage: 'Upstream returned an unexpected shape',
    })
  }
  const user: User = parsed.data
  return { label: user.email?.toLowerCase() ?? 'no email', plan: user.plan }
})
Enter fullscreen mode Exit fullscreen mode

Two things change. The null case is now a type error you have to handle, not a surprise in production. And a bad upstream response fails loudly with a clear status, instead of a vague TypeError deep in your code.

Where to validate, and where not to bother

You don't need a schema around every object. Validate where data crosses a trust boundary:

  • Responses from APIs you don't control
  • Request bodies and query params in your own server routes (readValidatedBody and getValidatedQuery in Nitro/H3 take a validator for this)
  • localStorage, cookies and anything else the user can edit
  • Output of any model or service whose format isn't guaranteed

Inside your own code, after the boundary, trust the types.

A tradeoff to be aware of

Schemas cost a little bundle size and a little time per parse. On the server this rarely matters. In a hot client path, parse once when the data arrives, not on every render.

Also, strict schemas can make you reject responses that were fine in practice. Decide per field: .passthrough() or .strip() for unknown keys, and be careful with strict enums on upstreams that add values without warning.

Takeaway

as SomeType is documentation. A schema is a check. Use assertions for things you actually know, and parse the rest at the edge.

Top comments (0)