No Escape Hatches
The type system isn't optional.
Every any, every unchecked unknown, every as cast and every @ts-ignore is a hole in the contract. The compiler stops checking; the bug waits for production to find it.
Opinion
I've watched this fight in three different codebases and the verdict is always the same: one any in March is forty by September. The escape hatch is not the bug; the escape hatch is the shape of the bug, the place where the next reader cannot tell if anyone ever did the work. any says nothing; as Foo says the author hoped it was a Foo; @ts-ignore says the author gave up. None of those are checked, none of those refactor with the rest of the code, and none of those tell the reviewer where the load-bearing assumption lives.
The rule has narrow exceptions, and I'll defend them as part of the rule, not against it. Some test setups need any to mock framework internals or boundaries that the test itself is the contract for. Some third-party shapes (legacy SDKs without typings, free-form JSON from an unowned upstream) cannot honestly be modelled. At the mocked edge between two systems, where the shape is the test's concern, the escape hatch is the right tool. Everywhere else it is technical debt with a friendly face. See T1 Domain-Driven Types: the escape hatch is the negative form of the same rule. T1 says encode the domain. T2 says do not bypass the encoding.
Copy a note and link
Grab this short comment and drop it into a PR comment or an LLM chat to prompt the right change.
No `any`, no unchecked `unknown`, no `as` casts (except `as const`), no `@ts-ignore`. Narrow exceptions belong in the commit message: test mocks, third-party shapes that cannot be modelled, mocked-boundary edges. Anywhere else, type the parameter or write the guard. /tenet/no-escape-hatches/T2
AI eyes only
Rule: the type system is not optional. No any, no unchecked casts.
Reject: any, unknown cast directly to a domain type, as without a runtime check, @ts-ignore, @ts-expect-error without a cited bug or upstream issue.
Generate: type the parameter explicitly. Write the type guard. Model third-party shapes with unknown plus a runtime schema (Zod / Valibot) before reaching the domain.
Diagnostic: search the diff for any, as , @ts-ignore. Each must be removed or justified inline against a referenced issue.
Why?
- Keeps the compiler doing the work it ships to do. Every
anyturns the type checker off for the values it touches; banning the escape hatch keeps the proof intact. - Forces honesty at the boundary.
JSON.parse,localStorage,postMessageand route input land asunknownand stay that way until a guard from T3 Type Guards says otherwise. - Refactors stay safe.
anyhides the shape; rename a field on a real type and the compiler walks the call graph for you. - Coding agents can't cheat the type system.
function process(data: any): anyis the model's favourite shortcut; the linter set to error makes the agent type the parameter instead. - Cuts review time. The shape of every value is in the signature; the reviewer reads the type, not the implementation, to know what came in.
- The narrow-exceptions list earns its keep. Test mocks for framework internals, third-party shapes you genuinely cannot model, and mocked-boundary edges get a written justification on the line above; everything else gets the refactor.
@ts-expect-errorrots out on its own. The day the underlying type bug is fixed, the directive errors and gets removed;@ts-ignorewould have stayed there forever.
Origins
The argument is older than TypeScript and broader than any one language. Tony Hoare called his 1965 invention of the null reference his “billion-dollar mistake” for exactly this reason: a type that admitted the bad case shipped the bad case every time, and the escape hatch — the silent null — cost the industry a billion dollars in production failures.5Null References: The Billion Dollar Mistake (QCon, 2009). Hoare named his 1965 invention of the null reference the canonical type-system escape hatch and totalled the industry cost. Benjamin Pierce's Types and Programming Languages (2002) gave the academic statement: a type system is a syntactic method for proving the absence of certain program behaviours. any withdraws the proof.6Types and Programming Languages (MIT Press, 2002), ch. 1. The academic statement: a type system is a tractable syntactic method for proving the absence of certain program behaviors.
In the TypeScript-specific lineage, the inflection point is Anders Hejlsberg's 2018 TSConf keynote announcing TypeScript 3.0 and unknown. Hejlsberg framed the new type as “the safe any” — explicit acknowledgement that the team had been losing the fight against the original escape hatch and now needed a typed replacement that forced narrowing before use.2TypeScript 3.0 release notes (Microsoft Developer Blogs, 2018). Introduces unknown as the safe any: explicit acknowledgement that the team had been losing the fight against the original escape hatch. Every TypeScript release since has tightened the rule from the language side: noUncheckedIndexedAccess (4.1), exactOptionalPropertyTypes (4.4), verbatimModuleSyntax (5.0). The language is doing the work of saying: don't silence me.
The tooling tradition is older still. Yaron Minsky's 2011 Effective ML talk argued that the discipline of “make illegal states unrepresentable” included “make absent values explicit” — the option-type pattern that ML, F#, Haskell, Scala and Rust have used for decades to refuse the null escape hatch.7Effective ML / Make Illegal States Unrepresentable (Jane Street, 2011). The ML/OCaml form of the discipline; the option type as the structural refusal of the null escape hatch. Dan Vanderkam's Effective TypeScript Item 38 codified the industrial form for the TS audience: limit the use of any to the narrowest possible scope, never let it leak out of a function.8Effective TypeScript (O’Reilly, 2019), Item 38. Limit any to the narrowest possible scope and never let it leak out of the function it lives in. The rule is not zero tolerance; it is zero silent tolerance.
Robert C. Martin would call the escape hatch a violation of the contract.9Clean Code (Prentice Hall, 2008). The escape hatch is a violation of the contract: the type promised something the runtime did not enforce. Joshua Bloch in Effective Java Item 16 frames the same discipline at the class boundary: hide the implementation; never let a caller depend on the absence of a check. F6 Encapsulation is the structural form of the rule; T2 is the type-system form. Both say the same thing: the contract is the promise; if you can break the promise without an explanation, the promise is a lie.
Quotes
unknown is the type-safe counterpart of any. Anything is assignable to unknown, but unknown isn't assignable to anything but itself and any without a type assertion or a control-flow-based narrowing.
A type system is a tractable syntactic method for proving the absence of certain program behaviors by classifying phrases according to the kinds of values they compute.
I call it my billion-dollar mistake. It was the invention of the null reference in 1965. My goal was to ensure that all use of references should be absolutely safe, with checking performed automatically by the compiler.
Use the narrowest possible scope for any types. Never return an any type from a function. This will silently lead to the loss of type safety for any client code that calls the function.
Evidence
Twenty external sources, ranked by author authority. The first five are the canon; expand to see the rest, including the qualifiers and the named opposers. Each links out to its primary source.
- 01The team shipping the language framed unknown as the safe any: explicit acknowledgement that any was the escape hatch they had been losing the fight against.
- 02Vanderkam codifies the industrial form of the rule: the any type is sometimes necessary, but it must never leak past the narrowest scope you can manage.
- 03The book-length statement of the rule. Item 5 is the canonical reference cited by every TypeScript style guide downstream.
- 04TypeScript Handbook — The any typeQualifiesThe handbook itself names any as an opt-out of the type checker and recommends turning on noImplicitAny. The qualifier: the language ships with the escape hatch and tells you not to use it.
- 05Handbook section that distinguishes unknown from any: unknown forces narrowing before use, which is the discipline T2 demands at the boundary.
Sixteen sources, one direction with caveats. The supports are the canon: Hejlsberg's release notes, Vanderkam's Effective TypeScript, and the TypeScript Handbook pages on any and unknown. The qualifiers further down carry the narrow-exception clause the rule has to earn. The opposers (gradual-typing apologists, dynamic-language counters) are mostly arguing for a smaller version of the rule, not against it.
Examples
// Before: any in, as out. The compiler stops checking at the boundary.function loadHedgehog(input: any): Hedgehog { const payload = JSON.parse(input); return payload as Hedgehog;}
// After: unknown in, guard validates, one logged exception for the SDK.function loadHedgehog(input: string): Hedgehog { const payload: unknown = JSON.parse(input); if (!isHedgehog(payload)) { throw new Error("invalid hedgehog payload"); } return payload;}// allowed exception: legacy SDK ships no typings; ticket HEDGE-412 to retire.import burrowSdk from "burrow-legacy-sdk" as any;
Enforcement
Apply these rules in eslint.config.mjs. The full enforcement across every tenet lives on the implementation page.
| Rule | Tool | Catches |
|---|---|---|
| @typescript-eslint/no-explicit-any | typescript-eslint | any explicit any annotation. The narrow exceptions live in eslint overrides for test files and vendored .d.ts shims. |
| @typescript-eslint/no-unsafe-argument | typescript-eslint | an any (or unknown) value flowing into a typed parameter without narrowing — closes the laundering route through function calls. |
| @typescript-eslint/no-unsafe-assignment | typescript-eslint | assignment of an any value to a typed variable — the way most any leaks start. |
| @typescript-eslint/no-non-null-assertion | typescript-eslint | the non-null assertion (!) — the same crime as as with extra steps. Forces a guard or a refactor instead. |
| @typescript-eslint/consistent-type-assertions | typescript-eslint | as casts on object literals (the silent any-bypass for fresh values) and inconsistent assertion styles. |
| @typescript-eslint/ban-ts-comment | typescript-eslint | @ts-ignore, @ts-nocheck, and undocumented @ts-expect-error. Forces every suppression to carry a justification of at least 10 characters. |
| tsconfig strict | TypeScript compiler | implicit any from un-annotated parameters, mis-typed null/undefined flows, and the rest of the strict family. The single setting that turns on most of the language’s safety net. |
| tsconfig noUncheckedIndexedAccess | TypeScript compiler | indexed access into a record without a presence check — the silent undefined that becomes a runtime crash. |
eslint.config.mjsconfiguration snippet
import tseslint from 'typescript-eslint';
export default tseslint.config({
files: ['**/*.{ts,tsx}'],
languageOptions: {
parserOptions: { project: './tsconfig.json' },
},
rules: {
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unsafe-argument': 'error',
'@typescript-eslint/no-unsafe-assignment': 'error',
'@typescript-eslint/no-unsafe-call': 'error',
'@typescript-eslint/no-unsafe-member-access': 'error',
'@typescript-eslint/no-unsafe-return': 'error',
'@typescript-eslint/no-non-null-assertion': 'error',
'@typescript-eslint/consistent-type-assertions': ['error', {
assertionStyle: 'as',
objectLiteralTypeAssertions: 'never',
}],
'@typescript-eslint/ban-ts-comment': ['error', {
'ts-ignore': true,
'ts-expect-error': 'allow-with-description',
'ts-nocheck': true,
minimumDescriptionLength: 10,
}],
}
});AI rules
.cursor/rules/t2-no-escape-hatches.mdc---
description: Prickles T2 — No Escape Hatches
globs: "**/*.{ts,tsx,js,jsx,py,java,php}"
alwaysApply: false
---
## Prickles T2 — No Escape Hatches
Forbid the four escape hatches by default: `any`, `unknown` without narrowing, `as` casts on anything other than `as const`, and `@ts-ignore` / `@ts-expect-error`.
Narrow exceptions, written down in the commit message, not in a code comment: test setups that mock framework internals or system boundaries; legacy third-party shapes that genuinely cannot be modelled; mocked edges where the test is the contract.
Where `unknown` enters the system from outside the type graph (JSON parsing, `localStorage`, `postMessage`, route input, error catch), narrow with a guard before the value flows further.
Prefer `@ts-expect-error` over `@ts-ignore`. The former errors when the underlying bug is fixed; the latter rots silently.
Refuse to satisfy a type checker by silencing it. If the linter trips, fix the type, write the guard, or model the boundary.Repo layout, CI, and ESLint wiring for these paths live on /implementation — not repeated on every tenet.
Counter-argument
Hejlsberg himself is the steelman. At TypeScript's 2012 launch he was explicit that TS is a gradual type system and any is a feature: the bridge that lets a JavaScript codebase migrate one file at a time.1TypeScript launch keynote (Channel 9, 2012). Hejlsberg framed TypeScript as a gradual type system: any is the bridge that lets a JavaScript codebase migrate one file at a time. The steelman for the rule and the bridge metaphor that the reply takes seriously. Take that seriously and the rule looks dogmatic: it bans the feature the language ships with. The dynamic-language counter goes further: types ossify a design before the design is known, and the cost of changing a pervasive type is steeper than the cost of a runtime adjustment. Sandi Metz's argument from The Wrong Abstraction applies here too: the wrong type is harder to remove than the wrong cast.
Counter-argument retort
Hejlsberg's gradual-typing argument was right at the time of writing and is wrong as a doctrine for the codebase that lives there now. Gradual typing was the bridge; the bridge has a far end. Once a project is past the migration, the escape hatch stops being a feature and starts being technical debt with a friendlier face.1TypeScript launch keynote (Channel 9, 2012). Hejlsberg framed TypeScript as a gradual type system: any is the bridge that lets a JavaScript codebase migrate one file at a time. The steelman for the rule and the bridge metaphor that the reply takes seriously. The TypeScript team itself agrees by action: the 3.0 release introduced unknown as “the safe any” precisely because the team had been losing the fight, and every release since has tightened the screws — strict defaults, noUncheckedIndexedAccess, exactOptionalPropertyTypes, verbatimModuleSyntax.2TypeScript 3.0 release notes (Microsoft Developer Blogs, 2018). Introduces unknown as the safe any: explicit acknowledgement that the team had been losing the fight against the original escape hatch. The language is voting against its own escape hatch.
The Metz objection — the wrong type is harder to remove than the wrong cast — is sharper, and it's the reason the rule has narrow exceptions instead of being absolute. A type-driven design can be wrong; a Zod schema for an external API can be over-specified; a branded primitive can outlive its usefulness. The exceptions list isn't an apology, it's part of the principle: at boundaries you don't own, model what you can and let unknown + a guard carry the rest. See T3 Type Guards for the runtime side.
The genuine residue is the four shapes the rule names. Each gets a default and a carve-out written down in the commit, not as a vibe. any: forbidden, except in test setups that mock framework internals and in shims for legacy modules awaiting a .d.ts. unknown: forbidden without narrowing, except at the system boundary where JSON.parse and localStorage and postMessage hand you a shape you have to validate downstream.3The unknown type. unknown is the principled alternative to any: it forces narrowing before use, which is the discipline T2 demands at the system boundary. as: forbidden on anything except as const, with the genuinely-witness-pattern carve-out for runtime invariants established elsewhere. @ts-ignore: forbidden in favour of @ts-expect-error, which errors when the underlying bug is fixed and the suppression is no longer needed.4ban-ts-comment rule. Polices @ts-ignore, @ts-expect-error and @ts-nocheck. Prefer @ts-expect-error: it errors when the underlying bug is fixed and the suppression is no longer needed.
In production code the escape hatch is the comment that lies. The next reader trusts the type they see; the type was a cast; a bug ships. Refactor — type the parameter, write the guard, model the boundary, file the ticket. The discipline is small. The cost of pretending you can skip it is large.
Notes
- [1]Anders Hejlsberg — TypeScript launch keynote (Channel 9, 2012). Hejlsberg framed TypeScript as a gradual type system: any is the bridge that lets a JavaScript codebase migrate one file at a time. The steelman for the rule and the bridge metaphor that the reply takes seriously.
- [2]Anders Hejlsberg — TypeScript 3.0 release notes (Microsoft Developer Blogs, 2018). Introduces unknown as the safe any: explicit acknowledgement that the team had been losing the fight against the original escape hatch.
- [3]TypeScript Handbook — The unknown type. unknown is the principled alternative to any: it forces narrowing before use, which is the discipline T2 demands at the system boundary.