As a tech lead, how would you decide where a non-null assertion (`!`) is acceptable in a TypeScript codebase and where it must be replaced by a real check?
answer
- classify, do not count
- where does the knowledge live
- parse at the edge, assert nowhere after
- distance between claim and proof
- a ban pushes people toward as casts
basics
~20 sJudge each assertion by where the knowledge lives. It is acceptable when the invariant is established visibly nearby and cheap for a reviewer to confirm; it is unacceptable on data crossing a trust boundary, where validation belongs once at the edge instead.
solid answer
~50 sI do not decide by counting assertions, I decide by classifying them. Three buckets cover almost everything. First, **boundary data** — parsed JSON, HTTP responses, environment variables, database rows, DOM lookups. Here the invariant is unknowable at compile time, so an assertion is a guess and the rule is validate once at the edge and type the result honestly, after which nothing downstream needs asserting. Second, **locally established invariants** — you stored the value one line above, you built the literal you are indexing. These are verifiable at a glance and I accept them, ideally with a short comment. Third, **lifecycle fields** filled by a framework or container, where a definite-assignment assertion is the localized, honest option provided the guarantee is real. Anything that does not fit a bucket is usually an author silencing an error they did not understand, which is where the actual bugs live. The governance layer is a default-deny lint rule with a documented escape, not a hard ban.
go deeper
Know that an assertion is a promise nothing verifies, and that the safe habit is to write a check unless someone senior has agreed the invariant is obvious from the surrounding lines.
Be ready to justify each assertion you write by pointing at the statement that makes it true, and to convert boundary ones into a check that throws with a message naming what was missing.
Argue the locality rule concretely and lead the refactor: validate once at the edge, type the validated result honestly, and remove the downstream assertions rather than reviewing them one by one.
Own the second-order effects. Explain where a naive ban pushes the escapes, choose the governance model, and treat the assertion census as a signal about which types are modelled too loosely.
## Start from what an assertion actually trades A non-null assertion costs nothing at run time and buys nothing at run time. What it does is convert a compiler error today into an invariant that no tool will ever check again. That framing turns the question from a style debate into a risk-allocation decision: an assertion is acceptable when the invariant is cheap for a human to re-verify whenever the surrounding code changes, and unacceptable when it is not. ## The three buckets **Boundary data.** Anything that entered the process from outside — a parsed response body, a config file, an environment variable, a query result, an element looked up in a document — has an unknown shape at compile time. The declared types are frequently optimistic already, so an assertion here compounds a guess with a promise. The policy is *parse, don't assert*: validate once at the edge with a schema validator or a hand-written guard, produce a type that reflects what was actually verified, and let everything downstream be assertion-free by construction. This is where nearly all incidents traced to `!` originate. **Locally established invariants.** `map.set(key, [])` on one line and `map.get(key)!.push(item)` on the next is a claim a reviewer can confirm without leaving the screen. Same for indexing a literal you just wrote, or a fixture built in the same test setup. I accept these. The cost of eliminating them — restructuring code so the compiler can follow the reasoning — is often higher than the risk, and the risk is bounded because the proof and the claim live together. My test is distance: if the assertion and the statement that makes it true cannot be read in one view, it is not this bucket. **Lifecycle fields.** Fields filled by a framework hook, a dependency-injection container, or a test setup function are the intended home of the definite-assignment assertion. I accept them when the guarantee is real and documented, and push first for the alternatives that remove the question: constructor injection, or a static factory that performs async setup and returns a fully built instance. The important constraint is *ownership* — the assertion belongs in the class that owns the lifecycle, never scattered across callers who merely hope initialization happened. ## Designing the assertion away The strongest version of this answer is structural. Most assertions exist because a type is describing a shape looser than the code's real contract. A function that returns `{ root: HTMLElement }` after doing the lookup and check itself removes the assertion at every call site. A discriminated union that separates the initialized state from the uninitialized one removes an entire class of definite-assignment fields. Every assertion is a small piece of evidence about where the model is imprecise, which makes a census of them a useful design signal, not just a compliance list. ## The governance layer Default-deny with a documented escape works better than either extreme. The `@typescript-eslint/no-non-null-assertion` rule flags the operator; the team norm is that a suppression comment must state the invariant in words. Review then asks exactly one question per assertion: *what makes this true, and can I see it from here?* A hard ban tends to backfire in a specific and predictable way: people write `as SomeType` instead. That is strictly worse — the cast silences shape errors as well as nullability, it survives unrelated type changes without complaint, and it is harder to grep for meaningfully because legitimate casts are common. Optimising the metric `count of !` while ignoring where the escapes migrate is a classic false win. ## What I would actually measure - Location mix rather than raw count: what fraction of assertions sit in boundary code versus local code, and is that fraction moving? - Incidents traced back to an assertion, which is the only outcome measure that matters. - Whether new boundary code arrives with a validator, since that is the intervention that removes the whole category rather than policing it. ## The interview point A principal-level answer resists both "ban it" and "it is fine, it is erased". It classifies by where the knowledge lives, names the migration risk of a naive ban, and proposes making the types honest so that most assertions have no reason to be written in the first place.
- Why can a hard ban on `!` make a codebase worse?Because the pressure does not disappear, it relocates. Developers write `as SomeType` instead, which silences shape errors as well as nullability, keeps compiling after unrelated type changes, and blends in with legitimate casts so it is far harder to audit. You end up with the same unverified invariants in a form that is broader and less visible. Default-deny with a justified escape keeps them greppable.
- How do you handle fields genuinely assigned by a framework lifecycle?A definite-assignment assertion is the right tool there, on two conditions: the guarantee is real and the assertion lives in the class that owns the lifecycle, not in callers who assume it ran. I still push for constructor injection or a static factory that returns a fully built instance first, because both remove the half-initialized state entirely rather than declaring it acceptable.
- What would make you conclude the policy is working?Not a falling count. I look at the mix: the share of assertions sitting in boundary code — parsing, config, HTTP, DOM — should trend toward zero as validators appear at the edges, while local assertions can stay flat. The outcome measure is incidents traced back to an assertion. If those persist while the count drops, the escapes have simply moved into casts.
saying these in an interview costs you the question
- Answers with a blanket ban and no alternative
- Treats every assertion as harmless because it is erased
- Ignores that as casts absorb the pressure from a ban
- Adds runtime validation at every call site instead of the boundary
- Judges assertions by count rather than location