An oracle that runs on every line, for ever, for nothing
A test checks the cases you wrote. A type checks every call site that exists and every one anyone writes later, including the ones produced at four in the morning by something that has never seen your codebase.
That asymmetry always favoured types. It favours them much more now, because the volume of code entering your repository went up and the proportion of it read carefully went down. A strict type checker is the only reviewer that scales linearly with the amount of code and never gets tired.
Turn on the settings that actually bite
Most projects have a type checker configured at a strictness where it catches almost nothing. The defaults are lenient on purpose, so adoption is easy. Adoption is not the goal.
TypeScript, in tsconfig.json:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true
}
}noUncheckedIndexedAccess is the one people skip and the one that pays. Without it, arr[10] is typed as the element type even when the array has three items, and every off-by-one becomes a runtime error instead of a compile error.
Python, with mypy or pyright:
[mypy]
strict = True
warn_unreachable = True
disallow_any_explicit = TrueThen the rule that makes it stick: Any and any require a comment explaining why, and # type: ignore requires the specific error code, not a bare ignore. A bare ignore silences everything on that line for ever, including errors introduced next year.
Make the types carry meaning, not just shape
This is where the real defect prevention is, and it costs almost nothing.
A function that takes (float, float, str) accepts arguments in the wrong order silently. A function that takes (Money, Rate, CurrencyCode) does not.
type Cents = number & { readonly __brand: "Cents" };
type UserId = string & { readonly __brand: "UserId" };
type OrderId = string & { readonly __brand: "OrderId" };
// passing a UserId where an OrderId is required is now a compile error,
// and it costs zero at runtimePython gets the same with typing.NewType, Rust with the newtype struct, Java with a small value class. Units, identifiers, currencies, timezone-aware versus naive datetimes, sanitised versus raw strings: every one of these is a category of bug that a type can make unrepresentable.
The Mars Climate Orbiter was lost in 1999 because one team worked in pound-seconds and another in newton-seconds. A newtype would have caught it at compile time. Every system that handles money in more than one currency has that bug waiting somewhere.
Parse, do not validate
A validation function returns a boolean and throws the information away. A parsing function returns a new type that carries the guarantee.
# validation: the guarantee evaporates immediately
if is_valid_email(s):
send(s) # nothing stops send() being called with anything
# parsing: the guarantee travels with the value
addr = EmailAddress.parse(s) # raises, or returns EmailAddress
send(addr) # send() accepts only EmailAddressThe consequence is that the check happens once, at the boundary, and every function downstream is relieved of re-checking, and, more to the point, cannot forget to. This one pattern removes a large fraction of defensive null checks and the bugs that live in the gaps between them.
Exhaustiveness is the check that survives change
function label(s: Status): string {
switch (s) {
case "draft": return "Draft";
case "sent": return "Sent";
case "paid": return "Paid";
default: {
const _exhaustive: never = s; // compile error if a status is added
return _exhaustive;
}
}
}When someone adds "refunded" to Status next quarter, this fails to compile and so does every other exhaustive switch in the codebase. That is a list of every place that needs attention, generated automatically, at the exact moment it becomes true. Rust’s match and Python’s assert_never do the same job.
The honest costs
Strict typing is not free and people who claim it is lose the argument with anyone who has done it.
- Retrofitting strictness onto a large untyped codebase is weeks, not days, and the middle of that migration is unpleasant.
- Type errors at the boundaries of dynamic data (JSON from an API, a database row, a YAML config) are where most of the friction lives. The answer is a parsing layer at each boundary, which is more code.
- Some type-level constructions cost more in complexity than the bugs they prevent. If a signature needs three lines of generics, ask whether the simpler design was available.
- Types cannot express most of what matters. “The discount is applied before tax” is not a type.
What makes the trade worth it in the current environment: a type checker rejects wrong code without anyone reading it, and that is now the constrained resource.
Every lesson here is open. Your progress is recorded once you pass the AI at Work exam.
Check your understanding
A codebase has strict: true in TypeScript but represents every identifier as string and every money amount as number. What class of defect remains completely undetected?
Pick the one you would defend. Your answer stays private.
Stuck on something?Ask Larissa, your AI tutor
