Discipline

We banned any, as, is and ! from our TypeScript

TypeFirst · AgileLabs Engineering · 7 min read

TypeScript's safety has a peculiar property: it's opt-out. The language ships four convenient little doors out of the type system, and every one of them is used thousands of times a day in production codebases to make red squiggles go away. In TypeFirst codebases, all four are lint errors, CI runs with --max-warnings=0, and there is no override comment culture. Here's why each one had to go.

any — the lie that spreads

function getUser(): any { … }

const user = getUser()
user.naem.toUpperCase()  // compiles. crashes at runtime.

any is not "unknown type" — it's "skip checking, and skip checking everything this value touches." It's viral: one any return type infects every variable it's assigned to, every property accessed on it, every function it's passed through. A single any in a popular utility can silently un-typecheck a quarter of your codebase. TypeScript already has the honest version: unknown, which you must narrow before use.

as — the assertion nobody verifies

const user = (await res.json()) as User
// the server renamed `name` to `fullName` last sprint.
// this still compiles. it will compile forever.

as tells the compiler "trust me." But the compiler was never the problem — reality was. The JSON didn't change because your annotation says so. Every as is a claim that's checked zero times, at the exact place (a system boundary) where data is most likely to surprise you. The honest version is to decode: parse the unknown value with a runtime validator that returns a typed Resultwe wrote a whole lesson on it.

is — the predicate the compiler takes on faith

function isUser(x: unknown): x is User {
  return typeof x === "object" && x !== null
  // forgot to check .name and .email — compiler doesn't care
}

Custom type guards look rigorous, but the body of an is function is unchecked: return true for the wrong thing and TypeScript will cheerfully narrow to a type the value doesn't have. It's as wearing a lab coat. Built-in narrowing — typeof, switch on a discriminant field, == null checks — does the same job and is actually verified by the compiler.

! — optimism as an operator

const el = document.querySelector(".profile")!
// narrator: it was null

The non-null assertion is a runtime crash you schedule in advance. null is information — "this might be absent" — and ! deletes the information instead of handling it. The honest version is two lines: check for null, decide what absence means here. If absence is truly impossible, that's a fact your types should express (make the impossible state unrepresentable) rather than a fact you assert at every use site.

What we do instead

What actually happened

The first two weeks are slower. The escape hatches exist because they're convenient, and without them you must learn the honest pattern for each situation — that's exactly the curriculum our Learn series teaches. Then it flips:

The rule only works because it's absolute. Allow "just this one justified as" and a year later you have hundreds, each one individually reasonable, collectively making your types untrustworthy again. Zero is the only number that's easy to enforce — and the compiler is the only reviewer that never gets tired.

See the enforcement. The ESLint configs that ban all four hatches live in devops/eslint in ts-bedrock — together with boundary rules that keep Core, Api and Web layers honest too.

← All posts