Skip to content
QSWEQB
hardConceptMidSeniorStaff

Where does TypeScript stop protecting you, and what do you do at those points?

Types are erased before the code runs, so anything crossing a boundary is unverified. The checker also has deliberate unsound spots - assertions, index access, bivariant method parameters, array covariance - so the answer is a validating parse per boundary plus knowing which flag closes which hole.

4 min readUpdated 2026-07-26

What the interviewer is scoring

  • Does the candidate raise erasure and the runtime boundary before listing compiler flags
  • Whether they can name a specific unsound construct and explain why the language accepts it
  • That they distinguish a type assertion from a validation, and treat as satisfies and a guard as three different acts
  • Whether the boundary strategy is parse-once-into-a-known-shape rather than checks sprinkled at use sites
  • Can they name the flag that fixes index access and say what it costs to switch on in an existing codebase

Answer

Every type is gone by the time the code runs

TypeScript compiles by deleting types. There is no runtime representation of an interface, no check inserted at a function boundary, nothing that inspects a value against its declared type. Which means a type annotation is a claim about the past — about what the compiler was able to prove from the source it could see — and says nothing about a value that arrived from somewhere the compiler never looked.

That covers more surface than people expect. JSON.parse is typed as returning any, so const user: User = JSON.parse(body) typechecks and asserts nothing. fetch(...).then(r => r.json()) gives you a Promise<any> for the same reason. Reading process.env.PORT gives string | undefined at best, and if you have declared it as string in a global augmentation the compiler will simply believe you. Anything from localStorage, a query string, a WebSocket frame, a postMessage payload or a database driver has the same character: the type is a wish.

The three ways you switch the checker off

Two are obvious and one is not. any disables checking on everything it touches and, worse, spreads: one any in a chain of inference silences errors well away from where it was written. as is a type assertion, which tells the compiler to stop reasoning and accept your word; the only thing it verifies is that the two types overlap enough to be plausible, and as unknown as T removes even that. The non-obvious one is !, the non-null assertion, which is exactly an as with a narrower scope and is therefore just as unchecked.

satisfies, added in TypeScript 4.9, is the one that does not switch anything off. It checks a value against a type while keeping the value's own narrower inferred type, which is what you usually wanted when you reached for an annotation.

// Annotation: checks, but widens. config.mode is string.
const a: Record<string, string> = { mode: 'dark' };
// satisfies: checks, and keeps the literal. b.mode is 'dark'.
const b = { mode: 'dark' } satisfies Record<string, string>;

Unsound by design, in three places

Index access is the one that bites daily. const first: string = names[0] typechecks on an empty array, because array and index-signature access is typed as returning the element type rather than T | undefined. noUncheckedIndexedAccess, added in TypeScript 4.1, changes that and is genuinely worth enabling — with the caveat that it is not part of strict, so you opt in separately, and on an existing codebase it surfaces a large one-off pile of errors concentrated in loops and lookup tables.

Method parameters are bivariant. strictFunctionTypes, on since TypeScript 2.6 and included in strict, makes function-type parameters checked contravariantly — but explicitly exempts methods declared with shorthand syntax, so that the built-in Array and Promise declarations continue to work as everyone expects. The result is that handler(e: MouseEvent) => void is accepted where (e: Event) => void is required, if the property was declared as a method.

Mutable arrays are covariant. Dog[] is assignable to Animal[], and once it is, pushing a Cat into it is a type error nowhere and a runtime surprise later. Using readonly Animal[] for parameters you only read is the practical answer, and it also documents intent.

Parse once, at the edge

The strategy that follows is not "add more checks" but "have exactly one place per boundary where an unknown value becomes a known one". Type the input as unknown so nothing can accidentally use it, run a function that either returns a value of the target type or throws, and let everything downstream trust the type because it was earned rather than asserted.

function parseUser(input: unknown): User {
  if (typeof input !== 'object' || input === null) throw new TypeError('not an object');
  const { id, email } = input as Record<string, unknown>;
  if (typeof id !== 'number' || !Number.isInteger(id)) throw new TypeError('id');
  if (typeof email !== 'string') throw new TypeError('email');
  // The return type is now backed by checks that ran, not by an assertion.
  return { id, email };
}

A schema library does the same thing with less code and gives you the type by inference rather than by duplication, which is the reason to prefer one. What matters for the interview is the shape of the argument: the boundary is where validation lives, and everything inside it is allowed to trust the types precisely because the boundary did not.

Note the residual hole even here. A function returning input is User is a predicate whose body the compiler does not verify against the claim — you can return true unconditionally and TypeScript will believe the narrowing. Since TypeScript 5.5 the compiler can infer such predicates for simple boolean-returning functions, which is safer because the inference is derived rather than declared, but an explicitly annotated predicate remains an assertion in a nicer coat.

Likely follow-ups

  • You add noUncheckedIndexedAccess to a mature codebase and get 900 errors. How do you land it?
  • Why is a user-defined type predicate a hole in the type system, and what changed in TypeScript 5.5?
  • Object.keys on a typed object returns string[] rather than the key union. Is that a bug or a decision?
  • How would you type the response of an endpoint you do not control, in a way that fails loudly rather than late?

Related questions

Further reading

typescripttype-soundnesstype-erasurevalidationcompiler-options