Skip to content
QSWEQB
mediumConceptCodingScenarioMidSenior

Your endpoint takes a typed request body and it still crashed on a missing field. How is that possible when it compiled?

TypeScript erases at compile time, so a type annotation on request data is an assertion about the future rather than a check. Anything crossing a boundary the compiler cannot see - a request body, a database row, a JSON file, an API response - has to be validated at run time, with the type derived from the validator.

4 min readUpdated 2026-07-26

What the interviewer is scoring

  • Whether the candidate states plainly that types do not exist at run time
  • That every untrusted boundary is enumerated rather than only the HTTP request
  • Does the candidate derive the static type from the validator instead of maintaining both
  • Whether `as` is identified as suppressing the check rather than performing one
  • That the response of an internal service is treated as untrusted too

Answer

The annotation was a promise, not a check

interface CreateOrder {
  customerId: string;
  items: { sku: string; quantity: number }[];
}

app.post("/orders", (req, res) => {
  const body = req.body as CreateOrder;   // no check happens here
  const total = body.items.length;        // TypeError if items is undefined
});

Compile that and inspect the JavaScript: the interface is gone and the as produced no code. TypeScript's types exist during compilation and are erased from the output, so an annotation on data arriving from outside is a statement of intent that nothing enforces.

as is the sharpest edge here because it looks like a conversion. It is not — it is an instruction to the compiler to stop checking, which is the opposite of a validation. Every as on external data is a place where a wrong assumption becomes a run-time crash with no compile-time warning, and by design there is nowhere for the compiler to warn.

Which boundaries are untrusted

Candidates almost always name the HTTP request body and stop, which leaves most of the surface unprotected. Anything the compiler cannot see the origin of is untrusted:

Query parameters and path parameters, which are always strings — page is "2", not 2, and Number(req.query.page) on absent input gives NaN rather than an error. Environment variables, all strings or undefined, typically read once at startup and typed by hope. Anything parsed from a file, since JSON.parse returns any and any disables checking for everything downstream of it. Message queue payloads, which were serialised by a producer on its own release schedule. Database rows, where the driver's generic type is whatever you told it and a migration can make that false.

And responses from other services, including internal ones. "It is our own API" is an argument about likelihood, not about types, and the other team's deploy is not gated on your assumptions.

Validate once, at the edge, and derive the type

The pattern that solves this properly is a schema that both validates at run time and produces the static type, so the two cannot disagree.

import { z } from "zod";

const CreateOrder = z.object({
  customerId: z.string().uuid(),
  items: z.array(z.object({
    sku: z.string(),
    quantity: z.number().int().positive(),
  })).nonempty(),
});

// The type is derived from the schema - there is no second declaration
// to fall out of step with it.
type CreateOrder = z.infer<typeof CreateOrder>;

app.post("/orders", (req, res) => {
  const parsed = CreateOrder.safeParse(req.body);
  if (!parsed.success) return res.status(400).json({ errors: parsed.error.issues });
  // parsed.data is CreateOrder, and that is now a fact rather than a claim.
  createOrder(parsed.data);
});

Deriving the type from the schema is the part that matters. Declaring an interface and a validator gives you two descriptions of the same shape maintained by hand, and the day someone adds a field to one and not the other you are back where you started, with the added confidence of having a validator.

Validating at the edge also means everything behind the edge can be honestly typed. The type on createOrder's parameter is now backed by a check that actually ran, so the internals need no defensive coding, and that is what makes the whole codebase's types worth trusting rather than just the ones the compiler could verify.

unknown over any

Where you must take in something untyped before validating it, type it unknown, not any.

any propagates: it turns off checking for every expression derived from it, so one any at the boundary quietly disables type safety across whatever chain of calls it flows into. unknown is the opposite — you can hold it, but you cannot use it until you have narrowed it, so the compiler forces the validation to happen. Configuring the framework so req.body is unknown rather than any turns a silent hole into a compile error.

Related: strict mode, and noUncheckedIndexedAccess in particular, which makes array[i] produce T | undefined and catches an entire family of these bugs at compile time.

Where the discipline pays off

The right mental model is a trusted core with validation at every entrance. Inside, types are guarantees and you write code that assumes them. At the boundary, everything is unknown until a validator has run.

That model also gives you good errors for free. A validation failure at the edge names the field and the reason, and returns a 400 the caller can act on. The same bad input caught three layers deep produces a 500 and a stack trace, and someone spends an afternoon working out which upstream sent it.

A type annotation on external data is a promise you made to the compiler about data you have not seen. Only a run-time check can keep it, and deriving the type from that check is what stops the two drifting apart.

Likely follow-ups

  • What does `as MyType` actually do to the emitted JavaScript?
  • Where else besides the request body does untyped data enter a service?
  • How do you avoid the schema and the interface drifting apart?
  • Is `unknown` better than `any` for a parsed body, and why?

Related questions

typescripttype-erasurevalidationzodboundaries