skip to content

Values entering a TypeScript service from JSON.parse, HTTP response bodies and untyped third-party modules all arrive typed as `any`. How do you keep that `any` from spreading into the rest of the codebase?

level: seniorimportance: should knowfreq 42%

answer

  1. find the doors, not the leaks
  2. JSON.parse and .json() both hand you any
  3. one wrapper per boundary
  4. unknown in, validated type out
  5. assertions check nothing at runtime

basics

~20 s

Type each boundary value as unknown at the point it enters, then validate it into a real type inside one wrapper per boundary, so no any crosses into application code. Assertions do not count — they check nothing at runtime.

solid answer

~50 s

I treat it as a boundary problem rather than a whack-a-mole problem. Every place external data enters gets exactly one wrapper function: inside it, the incoming value is annotated `unknown` — which stops the `any` immediately, because `unknown` is assignable to almost nothing — and the wrapper's declared return type is the real domain type. Between those two lines sits a runtime validation that actually inspects the data and throws or returns a failure on a mismatch. Callers never see `unknown` and never see `any`. What does *not* work is asserting with `as`: an assertion performs no check, so it produces exactly the same deferred runtime failure as leaving the `any` in place. For untyped dependencies I write a small declaration shim so the module has honest types instead of being implicitly `any`, and I rely on `noImplicitAny` plus type-aware lint rules to catch leaks that review misses.

code

typescript · 13 lines
typescript
type User = { id: string; name: string };

function toUser(value: unknown): User {
  if (typeof value !== "object" || value === null) throw new Error("not an object");
  const { id, name } = value as Record<string, unknown>;
  if (typeof id !== "string" || typeof name !== "string") throw new Error("bad user payload");
  return { id, name };
}

export async function fetchUser(url: string): Promise<User> {
  const body: unknown = await (await fetch(url)).json(); // any stopped at the door
  return toUser(body);
}

go deeper

for a junior

Know that data from JSON.parse or a response body is not really typed, and that writing an as annotation on it does not check anything at runtime.

for a middle

Explain why annotating the boundary value as unknown forces consumers to validate, and what a validator function's signature should look like: unknown in, domain type out.

for a senior

Show the whole containment design — inventory the doors, one wrapper each, validation that cannot drift from the type, declaration shims for untyped dependencies, and CI backstops rather than reviewer vigilance.

for a principal

Own the tradeoff: which boundaries are genuine trust boundaries worth the validation cost, how failures are surfaced and handled, and how the policy stays enforced as the codebase and team grow.

## First, know where the doors are You cannot contain `any` without an inventory of where it enters. In a typical service the list is short and predictable: - `JSON.parse` — declared in the standard library as returning `any`. - A DOM `Response`'s `json()` — declared as returning `Promise<any>`, so `await res.json()` is `any`. - A dependency that ships no type declarations — the whole module is implicitly `any`. - Explicit `as any` written by a colleague to unblock a build. - Deserialised or dynamically-keyed data structures typed as `Record<string, any>`. Everything else in the codebase is usually well typed. The leaks are not everywhere; they are at a handful of doors, and that is what makes the problem tractable. ## The containment principle: one door, one wrapper For each door, write exactly one function that owns it. Its **input** is the untrusted thing, its **output** is a real domain type, and the unchecked region lives entirely inside its body: ```ts type User = { id: string; name: string }; function toUser(value: unknown): User { if (typeof value !== "object" || value === null) throw new Error("not an object"); const { id, name } = value as Record<string, unknown>; if (typeof id !== "string" || typeof name !== "string") throw new Error("bad user payload"); return { id, name }; } ``` Callers of `toUser` receive a `User` and have every ordinary guarantee. Nothing downstream needs to defend itself, because the defence happened once. ## unknown is the tourniquet The key move is the annotation, not the validation. Writing `const body: unknown = await res.json()` converts an `any` into an `unknown` at the exact point of entry, and because `unknown` is assignable only to `unknown` and `any`, the compiler now refuses to let it flow anywhere useful. Every consumer that would have silently accepted bad data becomes a compile error until someone validates. You have converted an invisible risk into a build failure — which is the whole trick. This is why `unknown` is the right shape for a boundary type even before you have written any validation: it is the only annotation that makes the compiler pull in the same direction you are. ## Why `as` is not containment The common wrong answer is `const user = (await res.json()) as User`. It compiles, the editor autocompletes `user.name`, and precisely nothing was verified. Type assertions are a compile-time claim, not a conversion — types are erased, and no check is emitted. If the server renamed a field, the assertion's only effect was to move the failure from your build to your production logs while making the code *look* type-safe, which is worse than an honest `any`. Reserve assertions for the inside of a validator, where you have already established what the value is. ## Validation strategy Hand-written validators are fine for a handful of shapes and cost nothing at build time. Beyond that, a schema library that derives the static type from the runtime schema is usually the better trade: you declare the shape once and get both the check and the type, so they cannot drift apart — which is the failure mode of hand-written validators that someone forgets to update when the domain type changes. Whichever you choose, decide deliberately what happens on a mismatch: throw, return a result object, or degrade to a default. Silently coercing bad data is how the original problem comes back. The judgment call worth naming out loud: validating everything at every boundary costs code and a little latency. For a public HTTP boundary that is obviously worth it. For a value you just wrote to your own cache a millisecond ago, it may not be, and a documented `as` with a comment can be the right call. Seniority here is knowing which boundaries are actually trust boundaries. ## Untyped dependencies An untyped module is the leak you cannot annotate at the call site, because every export is already `any`. The fix is a small declaration file describing only the surface you use, typed honestly — and where you genuinely do not know a return type, type it `unknown` rather than `any` so the same discipline applies. This is far cheaper than typing the whole library, and it makes the dependency's real surface area visible in review. ## Backstops Two mechanical backstops keep the discipline from decaying. First, `noImplicitAny` — part of `strict` — so no new implicit `any` can appear. Second, type-aware lint rules that flag `any` values being assigned, called, passed as arguments or returned; these catch the *flow* of `any`, which the compiler by design does not. Both belong in CI, because containment that depends on reviewers noticing will not survive a growing team. ## How to answer this in an interview Name the doors, put `unknown` at each one, validate once into a real type, refuse to accept `as` as validation, and mention the flag plus lint backstop. Then add the judgment sentence: not every boundary is a trust boundary, and the cost of validation should be spent where untrusted data actually enters.

  • Why is `as User` a poor way to type a parsed response body?
    Because an assertion is a claim, not a check. Types are erased, so no verification code is emitted and the runtime value is whatever the server sent. The assertion silences the compiler and gives the code a false appearance of safety, so a renamed field fails in production instead of in CI — the same outcome as leaving the `any`, with more confidence attached.
  • Should you type your own internal function parameters as `unknown` to be safe?
    No. `unknown` is for values whose type you genuinely cannot know at compile time — external input. Inside your own code you do know the type, and using `unknown` there just forces pointless re-validation at every call while hiding the real contract. Reserve it for the boundary; past the boundary, use real domain types and let the compiler do its job.
  • How do you stop new `any` leaks appearing once you have cleaned up the existing ones?
    Put the backstops in CI rather than in review. `noImplicitAny`, which `strict` includes, prevents new implicit ones from appearing. Type-aware lint rules that flag `any` values being assigned, passed or returned catch the explicit ones flowing through code, which the compiler deliberately permits. Together they make a leak a failing build rather than something a reviewer has to spot.

saying these in an interview costs you the question

  • Casts the payload with as and calls it typed
  • Trusts the server contract instead of validating
  • Thinks enabling strict retypes existing any as unknown
  • Validates in every consumer instead of at the boundary
  • Types internal function parameters unknown for safety

context