As the lead on a shared TypeScript codebase, how do you decide how much type-level complexity to allow, and when should the guarantee move to a runtime validator such as Zod instead?
answer
- types are erased, so boundaries need real checks
- one schema, infer the type from it
- guarantee per unit of cost
- the editor pays on every keystroke
- budget it in CI, not in code review taste
basics
~20 sJudge each clever type by guarantee per unit of cost: build and editor latency, plus how many teammates can maintain it. Anything about data entering the program from outside cannot be guaranteed by types at all and belongs in a runtime validator, with the static types derived from that schema.
solid answer
~60 sI set the policy on two axes. First, where the data comes from: types are erased, so nothing in the type layer checks an HTTP response, an environment variable or a parsed file. Those boundaries get a runtime validator, and I derive the static type from the schema — with Zod, `type User = z.infer<typeof User>` — so there is one declaration instead of a schema and an interface drifting apart. Second, cost per unit of guarantee for internal modelling. A helper written once in a shared library, used in hundreds of places, preventing a real class of caller bug, usually earns its check time. The same helper inlined in a feature module, where a plain explicit interface would say the same thing, does not: it slows every teammate's editor and shrinks the set of people who can change the file. I make that concrete rather than a taste argument — a tracked type-check budget in CI, a rule that a shared computed helper needs type tests and a comment explaining its purpose, and a review question of "what bug does this prevent that an interface would not?"
code
typescript · 14 linesimport { z } from "zod";
// One declaration is both the runtime check and the static type.
const User = z.object({
id: z.string(),
age: z.number(),
});
type User = z.infer<typeof User>;
export async function loadUser(res: Response): Promise<User> {
const raw: unknown = await res.json();
return User.parse(raw);
}go deeper
Know that a type annotation on data from an API or environment variable does not check anything at run time, so parsing that data needs a real validation step.
Explain the single-source-of-truth pattern — define the schema once and infer the static type from it — and why maintaining a separate interface alongside a validator leads to drift.
Argue the tradeoff concretely for a real codebase: which guarantees are worth their check time and editor latency, and which are better expressed as a plain explicit type plus a runtime check at the boundary.
Own it as policy: a measured type-check budget in CI, a bar that shared computed types must clear, a default that complexity lives at library boundaries, and an honest account of the hiring and ramp-up cost the codebase is choosing to carry.
## Two different questions wearing one costume "How much type-level cleverness?" is really two decisions, and conflating them is what produces both over-engineered types and under-validated boundaries. **Decision one: is this guarantee even available from types?** TypeScript erases its type layer, so an annotation asserts nothing about data the program did not construct itself. The moment a value crosses into the program from outside — an HTTP response body, `process.env`, a config file, a message off a queue, a form submission, a database driver's untyped rows — a type is a claim, not a check. No amount of conditional-type sophistication changes that. If the risk you are managing is "the upstream service changed its payload", the answer is a runtime check, full stop. **Decision two: for data the program does own, is the elaborate type worth its cost?** This one is a genuine tradeoff with no universal answer, which is why interviewers ask it of leads. ## Boundaries: validate at run time, derive types from the schema The pattern I standardise on is one declaration that produces both the runtime check and the static type: ```ts import { z } from "zod"; const User = z.object({ id: z.string(), age: z.number() }); type User = z.infer<typeof User>; export function parseUser(raw: unknown): User { return User.parse(raw); } ``` The important property is not the library; it is that the schema is the single source of truth. Writing an `interface User` *alongside* a validator gives you two things to keep in sync, and they will diverge. Handing the boundary to a validator also collapses a whole category of type gymnastics: teams reach for elaborate helpers precisely because they are trying to make the type system express a guarantee about foreign data, and once the check is real the type can be plain. The standard I set: every process boundary has exactly one validated entry point, `unknown` is the type of anything not yet validated, and the parsed type is inferred rather than hand-written. ## Internal modelling: guarantee per unit of cost For types the program owns, I weigh three costs against one benefit. - **Build cost.** Check time, which creeps rather than jumps. - **Editor cost.** The bigger one, and the one people feel: the TypeScript server re-does this work as teammates type, so an expensive helper taxes every keystroke in every file that touches it. - **Human cost.** How many people on the team can read it, debug its error message, and change it safely. A type only one person understands is a single point of failure with none of the alerting you would demand of a service. Against: **how many real bugs does it prevent, in how many call sites?** That ratio is what separates a good clever type from a bad one. A helper in a shared library's public API, used in three hundred places, that makes an entire class of misuse un-compilable, is often worth minutes of build time. The identical technique used once in a feature module — where an explicit interface would have expressed the same constraint — is a bad trade at any price, because the guarantee was available for free. ## Making it a policy, not a taste argument Arguments about cleverness go badly when they are aesthetic. I make them measurable: 1. **A tracked type-check budget.** Record `tsc` duration, or the instantiation count from `--extendedDiagnostics`, in CI and alert on regressions. Then "this helper costs 40 seconds" is a fact in the pull request, not an opinion after the fact. 2. **A bar for shared computed types.** If a helper is exported for others to use, it needs: type tests pinning its behaviour, a comment stating the bug it prevents, and a reviewer other than the author who can explain it back. Failing any of the three, the plainer type ships. 3. **A default direction.** Application code prefers explicit types; library and framework-boundary code is where the investment is allowed. This puts complexity where it is amortised. 4. **A review question.** "What breaks if this were a plain interface?" If the honest answer is "nothing, but this is nicer", it is not nicer — it is more expensive. 5. **An exit.** Treat a type helper like any other component: if it becomes a maintenance burden, deleting it and writing the shapes out is a legitimate, non-embarrassing outcome. ## Hiring and continuity The part leads underweight is staffing. A codebase whose core abstractions require advanced type-level fluency narrows who can be productive in it and lengthens ramp-up. That is fine for a team that has deliberately chosen it and hires for it; it is a serious hidden cost for a team that drifted into it because one enthusiastic engineer had a good quarter. Deciding that consciously — and writing it down — is most of the job here. ## What a strong answer sounds like Not "clever types are bad" and not "types should catch everything". It is: erasure decides which guarantees are even purchasable statically, boundaries buy theirs at run time with the static types derived from the same schema, internal complexity is judged on guarantee per unit of build, editor and human cost, and the whole thing is governed with a measured budget rather than a style debate.
- Why derive the static type from the schema rather than declaring an interface and validating against it?Because two declarations drift. Inferring the type from the schema — `z.infer<typeof User>` with Zod — means the runtime check and the compile-time shape can never disagree, and a change to the schema immediately produces type errors at every place that relied on the old shape, which is exactly the signal you want.
- Where does elaborate type-level work most clearly earn its cost?At a widely used public API boundary — a shared library, an internal framework, a data-access layer — where the helper is written once, used in hundreds of call sites, and makes a real class of misuse fail to compile. The cost is paid once and amortised; the same technique used once inside a feature module is not.
- How would you make the tradeoff measurable rather than a matter of taste?Track type-check duration or the instantiation count from `--extendedDiagnostics` in CI and alert on regressions, so a costly helper shows a number in its own pull request. Pair that with a bar for shared computed types: type tests, a comment naming the bug prevented, and a second person who can explain it.
- Does validating at run time make strict typing less important?No — they cover different failures. Validation guards data crossing into the program; static types guard the code you write about that data once it is inside. Validation without strict typing lets internal mistakes through, and strict typing without validation leaves the boundary asserting rather than checking.
saying these in an interview costs you the question
- Believes a precise interface validates an API response
- Keeps a hand-written interface and a schema in parallel
- Judges type complexity purely on elegance, never on cost
- Assumes cleverness in a library and in app code cost the same
- Treats deleting a type helper as an admission of failure