skip to content

A TypeScript app starts with `const root = document.getElementById("app")!` and, after a template change, crashes in production with "Cannot read properties of null". Why did the type checker not prevent this, and how would you restructure the code?

level: seniorimportance: should knowfreq 48%

answer

  1. the union was right, the assertion overrode it
  2. nothing emitted, nothing to fail
  3. boundary data cannot be asserted
  4. distance between claim and proof
  5. trade a crash for a named error

basics

~20 s

getElementById is typed to return an element or null precisely because the element may be missing; the ! told the checker to drop the null case and emits no check of its own. Replace the assertion with an explicit test that throws a descriptive error at startup.

solid answer

~50 s

The typing was right and the assertion overrode it. `document.getElementById` is declared to return `HTMLElement | null` because a lookup by id can genuinely find nothing, and `!` removes that `null` from the type without emitting anything — so once the template stopped containing the id, the compiler had already been told not to care. Two things went wrong: the failure moved from build time to run time, and the crash message became a generic property-access TypeError with no hint about which lookup failed. I would replace the assertion with a check that throws a message naming the missing element, ideally through a tiny shared helper so the pattern is one line at every call site. More broadly I would treat every `!` on a lookup, a parse, or an environment read as an unvalidated boundary, and keep assertions only where the statement that makes them true sits a few lines above in the same function.

code

typescript · 9 lines
typescript
function must<T>(value: T | null | undefined, what: string): T {
  if (value === null || value === undefined) {
    throw new Error(`Expected ${what} to be present`);
  }
  return value;
}

const root = must(document.getElementById("app"), "the #app mount point");
root.textContent = "ready"; // root is HTMLElement, proven by a real check

go deeper

for a junior

Know that the assertion silenced the compiler and emitted nothing, so the null reached the property access unchanged. Say that a plain if check would have caught it.

for a middle

Explain why the return type is a union in the first place and how a check plus throw narrows it without any assertion, leaving the type as the element type for the rest of the function.

for a senior

Show production judgment: name both costs — the crash and the useless message — propose a shared helper that fails with context, and state the locality rule that decides which assertions survive review.

for a principal

Turn the incident into policy. Decide where validation lives for the whole system, so boundary data is parsed once into honest types and downstream modules never face the choice between an assertion and a check.

## Why the compiler stayed silent The DOM typings declare `getElementById` as returning `HTMLElement | null`. That union is a correct model of reality: the method looks up an id in a document the compiler has never read and cannot read. Adding `!` removes `null` from that union at the use site, and because the operator is erased, the emitted JavaScript is just the assignment. There is no check to fail, no guard to trip, and no record anywhere that a promise was made. When the template changed, nothing in the toolchain had any reason to speak up. This is the defining property of an assertion: it is correct exactly as long as the world around it stays the way it was when it was written, and it never announces that the world moved. ## Two separate costs The obvious cost is the crash. The subtler and often larger cost is the **diagnostic**. `Cannot read properties of null (reading 'appendChild')` names neither the id that was missing nor the fact that a mount point was expected. An on-call engineer gets a stack frame in bundled code and has to reconstruct the intent. A check written by hand costs one branch and produces a message that ends the investigation immediately. ## The restructure The smallest honest fix is a check that narrows and throws: ```ts const root = document.getElementById("app"); if (root === null) { throw new Error("Startup failed: no element with id 'app' in the document"); } root.replaceChildren(); // root is HTMLElement here, proven rather than asserted ``` After the `throw`, the checker knows the remaining code runs only when `root` is non-null, so the type is `HTMLElement` with no assertion involved. When this pattern recurs, factor it into a helper that takes the value and a label and returns the non-nullable type, so each call site is a single expression and every failure carries context. ## Where the assertion could have been kept Assertions are not always wrong, and a good answer says where they survive review. The workable test is **locality**: the statement that establishes the invariant should be visible from the assertion, in the same function, a couple of lines away. - Acceptable: you just wrote an entry into a map and read it back on the next line; you built the array literal being indexed; a test fixture assembled in the same setup block. - Not acceptable: anything derived from the document, a network response, `JSON.parse`, an environment variable, a database row, or a value handed in by a caller you do not control. These are boundaries, and boundaries deserve validation once, at the edge, with the parsed result typed honestly afterwards. That rule also explains why the failure here was predictable: the distance between the assertion and the thing that made it true was an entire HTML template in another file, maintained by a different change. ## Handling this at the scale of an existing codebase A useful sweep is a census of `!` occurrences ranked by location rather than count. Assertions in parsing, configuration loading, DOM access and HTTP response handling get fixed first, because those are where the invariant is genuinely unknowable. Assertions in tight local code can be left alone or given a one-line comment stating the invariant. Along the way, resist the reflex to "fix" each site with optional chaining: turning a crash into a silently skipped operation usually converts a loud failure into a data bug that surfaces days later. ## The anti-patterns to name - Swapping `!` for `as HTMLElement` changes nothing about safety and hides more, since the cast also silences unrelated shape errors. - Wrapping startup in a `try/catch` that logs and continues leaves the app running in a state the rest of the code was written to assume is impossible. - Blaming the DOM typing for "being annoying" — the union is the one part of this story that was accurate. ## The interview point Name the mechanism first (the assertion discharged the only check that existed, and emits nothing), then show the fix produces a *better* failure rather than merely a different one, then generalise with the locality rule so the answer is a policy rather than a patch.

  • When would you accept a non-null assertion in this same codebase?
    When the invariant is established in view. If the line above stored the value in the map I am now reading, or I am indexing an array literal I just wrote, the reviewer can verify the claim in one glance and the assertion is honest. My test is distance: anything crossing a function boundary, a module, an async gap, or a file the compiler never sees becomes a check instead.
  • How would you find the risky assertions in an existing codebase rather than fixing them one crash at a time?
    Take a census of `!` occurrences and rank by location, not count. Everything in parsing, configuration loading, DOM access and HTTP handling goes first, because those invariants are unknowable at compile time. For each, validate once at the edge and type the validated result honestly, so downstream code needs no assertion at all. Local assertions get a comment or are left alone.
  • Why not simply replace the assertion with optional chaining everywhere?
    Because it changes a loud failure into a silent one. `root?.replaceChildren()` makes a missing mount point do nothing at all, so the app renders an empty page with a clean console and the real cause surfaces much later. Optional chaining is right when absence is an expected, meaningful case; it is wrong as a blanket substitute for an invariant you actually depend on.

saying these in an interview costs you the question

  • Blames the DOM typings instead of the assertion
  • Says the compiler should have caught it regardless
  • Replaces ! with an as cast and calls it fixed
  • Adds optional chaining so the failure goes silent
  • Catches the error at startup and continues running

context