Runtime Validation
Where the type system stops and runtime checks begin — why TypeScript cannot trust an API response, schema validators like Zod and the types inferred from them, OpenAPI and GraphQL codegen, contract tests, and keeping generated types from drifting.
8 questions
SeniorDesignVery commonYou are reviewing a mid-sized TypeScript app whose validation is scattered. A Zod schema parses the login form; a hand-written type guard isUser checks API responses in one module while another module simply casts them with as User; and a branded type PaymentAmount rests on a check buried three layers inside the payment service. Reviewers cannot say which values in the app have actually been checked and which merely claim a type. Draw the boundary. Say what the type system can enforce on its own, once and for free; what only a runtime validator can establish and how often it has to run; and where in the app that validator should sit so everything inside the boundary can be trusted without re-checking.
You are reviewing a mid-sized TypeScript app whose validation is scattered. A Zod schema parses the login form; a hand-written type guard isUser checks API responses in one module while another module simply casts them with as User; and a branded type PaymentAmount rests on a check buried three layers inside the payment service. Reviewers cannot say which values in the app have actually been checked and which merely claim a type. Draw the boundary. Say what the type system can enforce on its own, once and for free; what only a runtime validator can establish and how often it has to run; and where in the app that validator should sit so everything inside the boundary can be trusted without re-checking.
Types enforce invariants only among values the compiler sees, and only at compile time. Data crossing a boundary — network, storage, JSON.parse, user input — must be parsed by a runtime validator at that edge. Validate once there.
Common mistakes
- ✗Using
asto confirm a shape — it performs no check and cannot fail - ✗Re-validating the same value at every layer instead of parsing once at the edge
- ✗Expecting the compiler to enforce an invariant that holds only for data it never sees
Follow-up questions
- →Which invariants are worth a branded type, and which belong in the schema instead?
- →How would you stop an internal module being called with data that skipped the edge?
JuniorTheoryCommonWhy can TypeScript not verify the data a fetch<User>() call actually returns?
Why can TypeScript not verify the data a fetch<User>() call actually returns?
Types are erased at compile time, so nothing about User exists at run time. The generic on fetch<User>() asserts a shape; it never checks the response. The data is really unknown until a runtime validator confirms it.
Common mistakes
- ✗Believing the generic on
fetch<User>()makes TypeScript check the response at run time - ✗Treating
as Useras a validation step — it performs no check and cannot fail - ✗Forgetting that
JSON.parsereturnsany, which silently disables every check below it
Follow-up questions
- →How does a runtime schema validator
Zodclose this gap? - →What does
satisfiescheck thatasdoes not, and when does each of them run?
MiddleDesignCommonYour team maintains a TypeScript client for an internal REST service. Today the request and response types are hand-written interfaces living in the frontend repo, kept in step with the backend purely by convention: whoever changes an endpoint is supposed to open a follow-up pull request against the client. The backend already publishes an API specification OpenAPI document, generated from its own route definitions and served at a versioned URL on every deploy. Twice this quarter a shipped field rename reached production before anyone touched the client, and the mismatch surfaced as undefined deep inside a React component rather than at the network boundary. Compare the hand-written approach with generating the client's types — and optionally the client itself — from that OpenAPI document. Say what the generator genuinely removes, what it cannot remove, and where a runtime check is still required.
Your team maintains a TypeScript client for an internal REST service. Today the request and response types are hand-written interfaces living in the frontend repo, kept in step with the backend purely by convention: whoever changes an endpoint is supposed to open a follow-up pull request against the client. The backend already publishes an API specification OpenAPI document, generated from its own route definitions and served at a versioned URL on every deploy. Twice this quarter a shipped field rename reached production before anyone touched the client, and the mismatch surfaced as undefined deep inside a React component rather than at the network boundary. Compare the hand-written approach with generating the client's types — and optionally the client itself — from that OpenAPI document. Say what the generator genuinely removes, what it cannot remove, and where a runtime check is still required.
Generation removes hand-maintenance drift: types come from the document the backend publishes, so a rename fails the build. It cannot prove the deployed server matches that spec, so responses are still validated at the boundary.
Common mistakes
- ✗Expecting generated types to prove that the deployed server matches its own spec
- ✗Treating codegen as a substitute for validating responses at the boundary
- ✗Regenerating only at release time, so the committed client silently lags the spec
Follow-up questions
- →What breaks if the API specification
OpenAPIdocument itself lags the deployed routes? - →Would you commit the generated client or regenerate it in CI, and why?
MiddleTheoryCommonWhy infer the type from a schema validator Zod instead of hand-writing an interface?
Why infer the type from a schema validator Zod instead of hand-writing an interface?
A hand-written interface and a separate validator are two sources of truth that drift silently: the compiler cannot see the check stop matching the type. Taking z.infer<typeof S> leaves one source — the check that runs.
Common mistakes
- ✗Keeping an
interfaceand a validator apart and expecting the compiler to catch the drift - ✗Thinking
z.infergenerates the schema from the interface, rather than the type from the schema - ✗Believing the schema is checked at compile time; only the
parsecall runs, at run time
Follow-up questions
- →What does
z.infer<typeof S>become in the emitted JavaScript? - →How would you handle a field the schema rejects but the app can still work without?
SeniorDesignCommonA legacy REST endpoint you cannot change returns three different shapes for the same resource, depending on which upstream served it: sometimes an object whose id is a number, sometimes one whose id is a string, and sometimes one where the whole user block is absent. Errors come back with HTTP 200 and an error field instead of a status code. The current client types the response as any, and failures show up as undefined errors in unrelated components hours later. You cannot fix the server and you cannot make it consistent. Design the typing and validation strategy at that boundary: how you model a response that legitimately has several shapes, what you do with a payload that matches none of them, and how the rest of the app is kept from ever seeing the raw response.
A legacy REST endpoint you cannot change returns three different shapes for the same resource, depending on which upstream served it: sometimes an object whose id is a number, sometimes one whose id is a string, and sometimes one where the whole user block is absent. Errors come back with HTTP 200 and an error field instead of a status code. The current client types the response as any, and failures show up as undefined errors in unrelated components hours later. You cannot fix the server and you cannot make it consistent. Design the typing and validation strategy at that boundary: how you model a response that legitimately has several shapes, what you do with a payload that matches none of them, and how the rest of the app is kept from ever seeing the raw response.
Model the endpoint's variants as a discriminated union in a schema, normalise them into one internal type at the boundary, and coerce the id there. A payload matching no variant is rejected; nothing past that edge sees the raw response.
Common mistakes
- ✗Typing an inconsistent response
anyand letting the failure surface far from the boundary - ✗Treating an HTTP 200 carrying an
errorfield as a success because the status code says so - ✗Making every field optional instead of modelling the real variants as a union
Follow-up questions
- →How do you handle a fourth shape appearing in production after the schema shipped?
- →Would you coerce
idtostringor tonumber, and what does that decision cost callers?
MiddleDesignOccasionalTwo TypeScript services — a checkout API and an orders worker — exchange an Order payload over a queue. Both import the same Order type from a shared npm package, and the team treats that shared import as proof the two agree: they compile against the same type, so surely they cannot disagree. Yesterday the checkout service shipped a version that renamed a field, published as a minor bump; the worker still had the previous version of the package in its lockfile and kept running for six hours, silently dropping every affected order. Both services compiled cleanly the whole time. Explain what a shared type does and does not guarantee between two independently deployed services, and design a checking strategy — including where cross-service contract tests fit — that would have caught this before the worker started dropping orders.
Two TypeScript services — a checkout API and an orders worker — exchange an Order payload over a queue. Both import the same Order type from a shared npm package, and the team treats that shared import as proof the two agree: they compile against the same type, so surely they cannot disagree. Yesterday the checkout service shipped a version that renamed a field, published as a minor bump; the worker still had the previous version of the package in its lockfile and kept running for six hours, silently dropping every affected order. Both services compiled cleanly the whole time. Explain what a shared type does and does not guarantee between two independently deployed services, and design a checking strategy — including where cross-service contract tests fit — that would have caught this before the worker started dropping orders.
A shared type constrains only code compiled against that version; each side erases it and pins its own copy, so the two can disagree at run time. Contract tests check the producer's real payload in CI, and the consumer validates on receipt.
Common mistakes
- ✗Treating a shared type as a runtime contract between two deployed services
- ✗Assuming both services run the same version of a shared package at the same time
- ✗Skipping validation on the consumer because the producer is already typed
Follow-up questions
- →What should the contract test assert — the producer's schema, or a recorded sample payload?
- →How would you roll out a field rename so both services stay compatible during the deploy?
MiddleDesignOccasionalA product team runs a query language GraphQL server written in TypeScript and a React client against it. Resolver argument and return types are hand-written interfaces sitting beside each resolver, and on the client every query's result is typed by hand from whatever the developer expects the selection set to return. The server publishes its schema as an SDL file, and every client operation lives in a .graphql document committed to the repo. Two bugs keep recurring: a resolver returns a field the schema declares non-null although the code can yield null, and a component reads a field the query never selected, which the compiler happily allows because the hand-written result type includes it. Compare hand-typing both sides with generating resolver types from the schema and operation result types from the schema plus the documents. State what generation guarantees, and what it still cannot guarantee about the data the client receives at run time.
A product team runs a query language GraphQL server written in TypeScript and a React client against it. Resolver argument and return types are hand-written interfaces sitting beside each resolver, and on the client every query's result is typed by hand from whatever the developer expects the selection set to return. The server publishes its schema as an SDL file, and every client operation lives in a .graphql document committed to the repo. Two bugs keep recurring: a resolver returns a field the schema declares non-null although the code can yield null, and a component reads a field the query never selected, which the compiler happily allows because the hand-written result type includes it. Compare hand-typing both sides with generating resolver types from the schema and operation result types from the schema plus the documents. State what generation guarantees, and what it still cannot guarantee about the data the client receives at run time.
Codegen derives resolver types from the schema and result types from the schema plus each document, so a non-null violation or an unselected field fails the build. Types describe the schema, not the response, which is checked at run time.
Common mistakes
- ✗Hand-writing client result types that include fields the query never selected
- ✗Believing generated types add runtime assertions — they erase like every other type
- ✗Assuming the client checks responses against the schema; nothing enforces it there
Follow-up questions
- →What does a code generator do with a field the schema marks as nullable?
- →How does the pipeline behave when a document selects a field the schema has dropped?
SeniorDesignOccasionalYour frontend generates its TypeScript client from the backend's API specification OpenAPI document. The generated file is committed and regenerated by hand, roughly whenever someone remembers. Last month the backend deployed a response change its own spec did not describe — a handler was edited without touching the annotation the spec is generated from — and the frontend kept compiling happily against a spec that no longer matched production for two weeks. Design a process that keeps the generated types honest: how and when generation runs, what fails the pipeline when the spec moves, and what catches the case where the deployed server disagrees with its own published spec. Say explicitly which of those two kinds of drift codegen can detect and which it structurally cannot.
Your frontend generates its TypeScript client from the backend's API specification OpenAPI document. The generated file is committed and regenerated by hand, roughly whenever someone remembers. Last month the backend deployed a response change its own spec did not describe — a handler was edited without touching the annotation the spec is generated from — and the frontend kept compiling happily against a spec that no longer matched production for two weeks. Design a process that keeps the generated types honest: how and when generation runs, what fails the pipeline when the spec moves, and what catches the case where the deployed server disagrees with its own published spec. Say explicitly which of those two kinds of drift codegen can detect and which it structurally cannot.
Regenerate in CI from the spec and fail the build on an uncommitted diff — that kills spec-to-client drift. Codegen cannot see server-to-spec drift: it only reads the document. Contract tests and boundary validation catch that.
Common mistakes
- ✗Believing codegen can detect a server that disagrees with its own published spec
- ✗Regenerating by hand instead of failing CI on an uncommitted generated diff
- ✗Treating the spec as evidence about the deployment rather than about the source it was built from
Follow-up questions
- →What should fail the pipeline when the spec changes but nobody regenerated the client?
- →Where would cross-service contract tests run — against a staging deploy or a recorded fixture?