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.
Top comments (0)