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 } }) 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 } }) 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 (
readValidatedBodyandgetValidatedQueryin 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.

