Problem
TypeScript never reports an any flowing into a typed position, and the reason is a single rule in the assignability relation: any is assignable to every type except never, and every type is assignable to any. Assignability is the only check that constrains the source type when a value reaches a declaration, a parameter, a return, or a contextually typed property — object-literal freshness and exactOptionalPropertyTypes constrain a literal's shape, not its assignability from any. So when the source type is any, that check succeeds against every target that matters, and it is not weak or heuristic, it is vacuous. No strictness flag changes it: strict, exactOptionalPropertyTypes and noUncheckedIndexedAccess leave it untouched, because none of them is about assignability from any.
noImplicitAny is the flag people reach for, and it does not cover this. noImplicitAny governs where a declaration may be left implicitly any — a parameter with no annotation, a variable the checker cannot infer. It says nothing about where an any that already exists is allowed to flow. JSON.parse is declared to return any in lib.es5.d.ts; that any is explicit in the standard library, so noImplicitAny has no opinion about it, and neither does any other flag. The same holds for require(), for a shorthand ambient module (declare module "x"), for a catch binding under useUnknownInCatchVariables: false, and for an any-typed property on a hand-written binding declaration.
What this costs at runtime: the moment an any is annotated as a concrete type, every downstream read of that value is checked against a shape nobody verified. const user: User = JSON.parse(raw) compiles with zero errors, and user.profile.email throws Cannot read properties of undefined in production against a payload that changed shape. The value is not merely unvalidated — it is laundered: from that binding onward the compiler tells every reader, and every editor hover, that it is a User, so the one place a reviewer might have asked "was this checked?" is the place the type says there is nothing to ask. The bug surfaces arbitrarily far from the parse, and the parse is the last place anyone looks.
How this relates to @typescript-eslint/no-unsafe-*
This is the obvious prior art and it should be addressed first. @typescript-eslint ships no-unsafe-assignment, no-unsafe-argument, no-unsafe-return and no-unsafe-member-access; they are type-aware, they are in the strict-type-checked preset, and the first three are positions 1+4, 2 and 3 of this proposal under different names. This proposal is that family plus Effect-aware origin analysis, Effect-aware remediation, and per-position codes. Concretely, four things here are not in no-unsafe-*:
- The contextually-typed-parameter origin exclusion. Effect's
dual and Effect.fn accept their implementation through a (...args: Array<any>) => any slot, which types every implementation parameter any whatever the author wrote. Measured below: 49 of the first 85 reports on a real Effect codebase — 58% — were this one class. A rule without that exclusion is not usable on Effect code, and no-unsafe-* does not have it.
- The
arguments-object origin exclusion, which is the same idiom spelled through arguments[0]: a further 62 reports on Effect-TS/effect.
- A shared origin walk that follows an
any back through a conditional, a binary operator, an access chain and an unannotated const binding, so an as any carried outward is attributed to the assertion rather than to the site that merely relays it — and, symmetrically, so an assertion that launders nothing is not attributed to the assertion.
- Remediation that names the Effect fix for the source it found —
Schema.decodeUnknownSync / Schema.decodeUnknownEffect for a JSON.parse source, unknown plus a guard for a catch binding — and one diagnostic code per position, which no-unsafe-assignment cannot express because it merges the declaration and object-literal cases.
If the maintainers would rather point users at no-unsafe-* and ship only the Effect-specific parts, that is a legitimate outcome, and the exclusions above are the parts worth keeping.
How this relates to preferSchemaOverJson
preferSchemaOverJson is syntactic and Effect-scoped: it matches JSON.parse / JSON.stringify call sites inside Effect.try or an Effect.gen / Effect.fn body. It is a good rule and this one does not replace it. They answer different questions — one asks "is this call spelled the Effect way?", the other asks "did an unvalidated value just acquire a type it never earned?" — and the measurement says so plainly. On Effect-TS/effect's packages/effect/src, on the same corpus at the same commit under the same tsconfig, each rule at error in its own run: preferSchemaOverJson reports 17 diagnostics across 15 distinct lines, this rule reports 474, and the two share zero file-and-line positions. On the private corpus below, preferSchemaOverJson reports 0 and this rule reports 32. Neither rule subsumes the other on either corpus.
This sits beside the two existing soundness rules — unsafeEffectTypeAssertion and anyUnknownInErrorContext, the only two correctness-group rules that default to off — as a correctness-group, default-off, opt-in diagnostic.
Bad — compiles cleanly, the rule should flag this
Every block below was compiled in a throwaway project with @effect/tsgo 0.45.0 (tsc reporting Version 7.0.2+effect-tsgo.0.45.0 after effect-tsgo patch --typescript) against effect@4.0.0-rc.112 under "strict": true, and exits 0 with zero diagnostics. Nothing here is a type error today.
// RULE: anyEscapesIntoTypedPosition
// BAD: `JSON.parse` is declared to return `any`. All four positions accept it
// silently, because the assignability check that guards each one is vacuous
// when the source is `any`. Nothing downstream will ever be told these values
// were not verified.
interface User {
readonly id: string
readonly age: number
}
declare const raw: string
declare const untypedValue: any
// position 1 — a declaration with an explicit annotation
export const parsed: User = JSON.parse(raw)
// position 1 — assignment to an already-typed target
export let current: User = { id: "a", age: 1 }
export function refresh(): void {
current = JSON.parse(raw)
}
// position 2 — an argument matched to a parameter with a concrete type
export function describe(user: User): string {
return user.id
}
export const described = describe(untypedValue)
// position 3 — a `return` against a declared return type
export function load(): User {
return JSON.parse(raw)
}
// position 4 — a property in an object literal that has a contextual type
export const built: User = { id: untypedValue, age: 1 }
// RULE: anyEscapesIntoTypedPosition
// BAD: a `catch` binding annotated `any` (or left implicit under
// useUnknownInCatchVariables: false) is the second source. `error` here can be
// a string, a number, or a rejected non-Error — the annotation says otherwise
// and the caller will read `.stack` off it.
export function failureOf(run: () => void): Error {
try {
run()
return new Error("no failure")
} catch (error: any) {
return error
}
}
The third source is a module with no types. It takes two files, so here they are separately.
// legacy.d.ts
// A shorthand ambient declaration types every export of the module `any`. This
// is what a hand-written shim for an untyped dependency, a `require()` result,
// or an `any`-returning native binding looks like to the checker.
declare module "legacy-config"
// config.ts
// RULE: anyEscapesIntoTypedPosition
// BAD: `readConfig()` is `any`, and both positions accept it silently.
import { readConfig } from "legacy-config"
interface Config {
readonly host: string
readonly port: number
}
export const config: Config = readConfig()
export function apply(_config: Config): void {}
export const applied = apply(readConfig())
Good
// RULE: anyEscapesIntoTypedPosition
// GOOD: decode instead of annotating. `Schema.decodeUnknownSync` takes
// `unknown`, so the `any` lands on an `unknown` parameter — which this rule
// deliberately never reports — and the concrete type is produced by a check
// rather than asserted by a declaration.
import { Schema } from "effect"
interface User {
readonly id: string
readonly age: number
}
const UserSchema = Schema.Struct({ id: Schema.String, age: Schema.Int })
declare const raw: string
export const decoded: User = Schema.decodeUnknownSync(UserSchema)(JSON.parse(raw))
// GOOD: or annotate the source `unknown` and narrow it explicitly.
export const opaque: unknown = JSON.parse(raw)
export const narrowed: User | null = Schema.is(UserSchema)(opaque) ? opaque : null
// RULE: anyEscapesIntoTypedPosition
// GOOD: leave the catch binding `unknown` and narrow it. Turning on
// useUnknownInCatchVariables makes this the default and removes the source
// entirely — which is why this source produced zero reports on both corpora
// measured below.
export function safeFailureOf(run: () => void): Error {
try {
run()
return new Error("no failure")
} catch (error) {
return error instanceof Error ? error : new Error(String(error))
}
}
Proposed rule behavior
A type-aware rule. For each expression whose checker type is any, decide whether it sits in one of four positions with a concrete target type, and if so report once, at the expression.
Positions, each with its own diagnostic code
One code per position so a reader can tell the flow apart from the diagnostic alone. {0} is the target type, {1} is a source-directed remediation:
| Code |
Position |
Proposed message |
TS377140 |
a variable or property declaration carrying an explicit type annotation, and the left side of an = assignment whose target has a concrete type |
This `any` value is assigned to a target typed `{0}`. TypeScript accepts it silently because an assignability check against a source of type `any` succeeds against this target. {1} |
TS377141 |
an argument matched positionally to a parameter with a concrete type, in a call or a new expression |
This `any` value is passed to a parameter typed `{0}`. … |
TS377142 |
a return in a function with a declared non-any return type, plus the expression body of an arrow with a declared or contextual return type |
This `any` value is returned from a function declared to return `{0}`. … |
TS377143 |
a property in an object literal that has a contextual type |
This `any` value initializes a property whose contextual type is `{0}`. … |
The wording deliberately does not say "because any is assignable to every type". That is false — any is not assignable to never — and this rule's own never target exclusion depends on the exception.
The remediation names the fix for the source that was found:
- a
JSON.parse source: `JSON.parse` returns `any`: decode the result with `Schema.decodeUnknownSync(schema)` or `Schema.decodeUnknownEffect(schema)` instead. — on v3, Schema.decodeUnknown(schema) in place of decodeUnknownEffect.
- a
catch binding source: This value comes from a `catch` binding: type it `unknown` (enable `useUnknownInCatchVariables`) and narrow it with a type guard or `Schema.is(schema)` before using it.
- otherwise:
Annotate the source as `unknown` and narrow it, or decode it with Schema.
Reading the target. For an annotated declaration the target is read off the annotation's type node, not off the declared name, so a narrowed name type can never stand in for the declared one. For an = assignment the same rule applies, via the target symbol's declared type rather than its control-flow-narrowed type at the assignment — otherwise let u: User | null = null; if (c) { u = jsonAny } reports against the narrowing instead of against User | null. When the target is not a simple reference (an element access, a destructuring pattern) and no symbol resolves, fall back to the type at the location. For an async function declared Promise<T> the target is T via GetPromisedTypeOfPromise, so the message names the type the author meant. Compound assignment (+=, ??=, ||=) is out of scope for v1 — += genuinely reads the target as well as writing it, and the logical forms were not separated out; that is a decision, not an oversight, and the logical-assignment operators are where it is least defensible.
Excluded targets
A target is concrete only when it is none of these:
any — the destination makes no claim, so nothing is laundered.
unknown — this is the shape the rule exists to steer people toward, and reporting it would punish the fix.
void — the value is discarded.
never — any is the one type not assignable to never, so TypeScript already errors; reporting would duplicate it.
- a naked type parameter — for a generic call it is normally inferred from the very argument being checked, so it carries no independent expectation.
- An overload resolving to an
any parameter needs no special case: the resolved signature's parameter type is any, so the any-target exclusion already covers it.
A rest parameter is NOT excluded. An argument in the rest tail is checked against the rest parameter's element type. This matters because the obvious shortcut — skip the rest tail so console.log stays quiet — is both unnecessary and lossy. console.log is declared log(...data: any[]), so its element type is any and the any-target exclusion silences it with no rest-specific rule at all; meanwhile function record(...events: DomainEvent[]) has a concrete element type and an any reaching it is a real escape. Measured: switching from a blanket skip to element extraction changed the private corpus by 0 and added 7 reports on Effect-TS/effect — six Array.prototype.push and one Math.max(...values: number[]), which is the better example, because nobody expects a logging-shaped variadic there. A call carrying a spread argument stops the scan at the spread; arguments before it are still positionally aligned and are still checked. A tuple rest parameter (...args: [a: string, b: number]) has no single element type and is out of scope for v1: the prototype's element lookup returns nothing and the argument is skipped.
The assertion carve-out, and the rule it depends on
An expression that is itself a type assertion — as T, <T>expr, satisfies T, as const — is not reported here.
State the dependency plainly: no rule in this repository ships coverage of as any into a concrete typed position today. unsafeEffectTypeAssertion only detects assertions that narrow an Effect, Stream or Layer error or requirements channel; it does not fire on const user: User = raw as any. A companion proposal for an explicit-assertion rule is being prepared alongside this one and would cover exactly that shape; it should be read as this proposal's other half, and if it does not ship, this rule should own the case at a fifth diagnostic code rather than leave it uncovered. (typescript/no-unsafe-type-assertion also already appears in this repository's own generated oxlint-schema.json, alongside the rest of the no-unsafe-* family, which is worth knowing when weighing whether a separate rule is wanted at all.)
Sizing this honestly: extending the carve-out to carrier forms removed 58 reports here, but 58 is what the carve-out removed, not the size of the class it declines to own — the underlying population of explicit as any assertions on this corpus is far larger. It is still the single largest class the proposal declines to own.
The carve-out also has to go one step further than the syntactic check, in both directions, and this is where a naive implementation leaks:
const value = flag ? (x as any) : 0 // conditional
const merged = (x as any).length // access chain
const loose = x as any; use(loose) // an unannotated `const` binding
In all three the reported expression is not an assertion but the any is the assertion's, so the walk must follow the forms that carry an any without producing one — a conditional, a non-assignment binary operator, an access chain, await, a non-null assertion, and an unannotated const binding. Measured on the Effect repo, adding this cut 564 reports to 506.
The converse matters just as much. An assertion whose operand is already any launders nothing — deleting it would not change the escape — so the real origin lies upstream and an assertion-focused rule would report the wrong expression and suggest the wrong fix. box.payload as any where box: Box<any> must still be reported; box.payload as any where box: Box<string> must not. This was found by hand-vetting the excluded set (below), not by watching a count, and it is the difference between a carve-out and a hole.
Excluded origins
Three origins are any without being a value entering the program — and the first three below are one idiom in three spellings, which is the single most important thing in this section. All must be excluded or the rule is unusable on Effect code, and each is stated here in the broad form that is actually implementable, with its costs, rather than in a narrower form that sounds better.
An UNANNOTATED parameter. Effect's dual and Effect.fn accept their implementation through a slot shaped (...args: Array<any>) => any, and a Node EventEmitter listener arrives through (...args: any[]) => void. A parameter in such a slot is typed any whatever the author wrote, because the contextual type wins over an initializer:
import { dual } from "effect/Function"
export const priced: {
(item: Item, quantity?: number): Money
(quantity?: number): (item: Item) => Money
} = dual(
(args) => args.length >= 1,
(item: Item, quantity = 1): Money => {
// `quantity` is `any`, not `number`. If it were `number` this line would be
// error TS2322; compiled against effect@4.0.0-rc.112 under `strict` it is
// accepted silently. That is the measurement behind this whole exclusion.
const probe: string = quantity
void probe
return times(priceOf(item), quantity)
}
)
Only an unannotated parameter qualifies: an explicit annotation always wins over the contextual type, so an annotated parameter that is any was annotated any — including via an alias, type Loose = any, which means the predicate must resolve the annotation's type rather than match the any keyword — and that is the author's own claim and is reported.
Under noImplicitAny: false this exclusion absorbs the entire implicit-any parameter class, not just the contextual-slot class it was written for, because every unannotated parameter is then any and unannotated. That is a much larger set. Under strict the language itself covers it (TS7006), so the exclusion only widens where noImplicitAny is already off.
The cost of stating it this broadly, measured and disclosed: a callback parameter whose type a .d.ts declares any is also unannotated at the call site, and it is also excluded — even though it is an author-declared untyped boundary rather than an implementation slot. The asymmetry is real and reproducible:
declare function onEvent(name: string, cb: (payload: any) => void): void
declare function readEvent(name: string): any
onEvent("user", (payload) => { const u: User = payload }) // NOT reported
export function readOne(): User { return readEvent("user") } // reported
Same declared-any boundary, same value, two arrival routes, one reported. The principled narrowing — exclude only when the parameter's index falls in the contextual signature's rest tail with an any element — was implemented and measured, and it does not work: GetContextualType does not resolve dual's generic slot (F extends (...args: Array<any>) => any), so the dual exclusion collapsed and the private corpus went from 32 reports back to 86. Neither GetBaseConstraintOfType on the contextual type nor falling back to the enclosing call's parameter type recovered it. An implementer who wants the narrow form needs a checker surface that resolves a generic implementation slot's constraint signature; that surface is the blocker, not the idea. Until then the broad form is what ships, and the false negative above is a known, stated limitation.
One further correction to the usual justification: the exclusion is often defended as "what reaches the parameter is constrained by the public overload". For dual that is true. For a bare Effect.fn(function* (recipeId = DEFAULT) {…}) there is no declared overload, and only the in-file call sites constrain the parameter. Four sites in the vetted sample were of that kind. They were still not bugs, but the rationale is narrower than the exclusion, and an implementer should know which part is load-bearing.
A REST parameter whose element type is any. This is the hand-written spelling of the same slot, and excluding it is not optional — it is required for consistency:
export const withPermit: {
(self: TxSemaphore): <A, E, R>(effect: Effect<A, E, R>) => Effect<A, E, R>
<A, E, R>(self: TxSemaphore, effect: Effect<A, E, R>): Effect<A, E, R>
} = ((...args: Array<any>) => {
const [self] = args // `self` is `any`
return Effect.acquireUseRelease(acquire(self), …)
})
args is annotated Array<any>, not any, so a predicate that only asks "is the annotation any?" reports this while excluding the dual-contextual spelling and the arguments[0] spelling of the identical dispatch. This was a live regression in the prototype: reporting it cost 24 reports on Effect-TS/effect — 14 in TxSemaphore, 8 in TxReentrantLock, 2 in Schema — before the rest-of-any case was excluded with the other two. Three spellings of one idiom must be decided together.
A read off the implicit arguments object, which declares [index: number]: any. Effect's overload implementations forward through arguments[0] exactly as they forward through a contextually typed parameter. This was a further 62 reports on the Effect repo. The mechanism is specific: an element access whose base is an identifier spelled arguments that resolves to no declaration; a user binding named arguments resolves and is unaffected.
Attribution rule that all of these depend on. An origin walk through an access chain must consult the base only when the base is itself any. If the base carries a concrete type then the access produced the any — request.payload where request: Entity.Request<any>, or an any-typed property on a binding declaration — and the base's origin says nothing about it. Getting this wrong silently discards the "any-typed property" source the rule claims to cover. Fixing it, bundled with the parameter-predicate narrowing above, recovered 45 reports on Effect-TS/effect; see the correction table below for why bundling those two was a mistake.
One diagnostic per escape site
Report at the expression, once, never once per downstream use.
- An
any bound to a name and used at many typed positions produces one diagnostic per use. That is correct — each is a distinct escape — but a small number of upstream anys can dominate a report. It happened here: 17 of the 32 private-corpus sites trace to three copies of one helper shape across nine const document = yield* … bindings, so the corpus has 18 distinct any-producing expressions behind 32 reports.
- Deduplicate by start position, because one expression can be reachable from more than one visitor — and specify how the collision resolves, because otherwise the emitted code becomes a function of traversal order and the "one code per position" premise stops holding. The prototype takes the first visitor to reach the position in a single parent-before-child pre-order walk, so on a nested collision the shallower position wins. That is reproducible but it is a consequence rather than a decision; a real implementation should compare on collision against a stated precedence instead of returning early, and sort stably on
(start, code).
Out of scope for v1
- No code fix. The right replacement depends on a schema the rule cannot invent.
- No cross-statement flow analysis. The one exception is resolving an unannotated
const back to its initializer, which is alias resolution rather than flow analysis: a const with no annotation is a name for its initializer. Cap the chain — the prototype uses 4 hops — and state the direction of the cap: past it the prototype answers "not excluded", so it reports. That trades a possible false positive on a five-hop alias chain for never silently dropping an escape, which is the right direction for a soundness check, but it is a decision and not an implementation detail.
yield / yield* operands, and a return inside a generator: the declared type describes the generator, not the value.
- A
return in a block body whose return type comes only from the contextual signature. const f: () => User = () => JSON.parse(raw) reports; const f: () => User = () => { return JSON.parse(raw) } does not, because the block-bodied path uses the declared annotation only. Two spellings of the same code, one reported — this will be filed as a bug on day one, and extending the contextual fallback to the block-bodied path is the first thing to fix after v1.
- Array literal elements, template literal holes, parameter default values, and JSX attributes — the last is a genuine fifth position and its absence is a v1 scope decision, not an oversight.
- Declaration files are skipped entirely.
- An expression whose type failed to resolve. The checker represents it with a type that carries the
any flag but whose intrinsic name is error (likewise unresolved and intrinsic). Checking the flag alone reports every unresolved expression as an escape. Printing the type does not discriminate either — the error type prints as any. Only the intrinsic name separates them. This produced a live false positive (error.message where error: unknown) before it was fixed.
False-positive analysis
The criterion, stated before it was applied: a report is a false positive when the any originates from a declaration whose purpose is to type-erase a value the program never reads through the claimed type. An any that is read through the type it was given is a true positive whether or not the author knew.
Vetted all 32 reports on the private corpus below — not a sample — across 4 packages and all 4 codes. 29 true positives, 3 false positives: a 90.6% true-positive rate, or 15 of 18 counting by distinct any-producing expression.
The three false positives are one class: an asymmetric test matcher. expect.arrayContaining([...]) and expect.any(Number) are declared to return any so they can stand in a typed slot, and the matcher protocol — not the parameter's declared type — is what reads them. They fail the criterion exactly. The class is not excluded by the spec above, is offered as a known limitation, and disappears if a consumer scopes the rule away from test code.
Two reports that the criterion keeps as true positives, because a reader might expect otherwise: Object.create(Worker.prototype) annotated Worker, and Object.fromEntries(...) resolving to its any-returning overload. Both are any-returning by declaration, but in both cases the value is subsequently read through the claimed type, so they are laundering, not erasure.
The false-positive classes that were found and that the spec above excludes, with the measured counts. The two corpora are kept separate because they have different denominators:
Private corpus — the measured progression is 85 → 51 → 36 → 32:
| Class |
Delta |
Excluded by |
a parameter contextually typed any by a (...args: Array<any>) => any slot |
−34 |
the unannotated-parameter origin exclusion |
| the same class reached through an object-literal shorthand property |
−15 |
GetShorthandAssignmentValueSymbol — the ordinary lookup answers with the property the literal declares, not the variable supplying its value |
the same class one hop away, through const next = depth + 1 or a conditional |
−4 |
sharing the origin walk across carrier forms |
So the contextually-typed-parameter class is 49 of 85 — 58%, not the 34 that the first fix alone removed. It is by a wide margin the most important thing in this proposal.
Effect-TS/effect — 564 → 506 → 444, then corrections back up and down to 474:
| Class |
Delta |
Change |
an as any carried outward by a conditional or an access chain |
−58 |
extending the assertion carve-out to carrier forms |
arguments[0] in an overload implementation |
−62 |
the arguments-object origin exclusion |
| (correction) an argument in a rest tail with a concrete element type |
+7 |
element-type extraction instead of a blanket skip |
(correction) an assertion whose operand was already any |
+2 |
walking through a redundant assertion |
(correction) an any produced by an access on a concrete base, wrongly attributed to the base, bundled with narrowing the parameter predicate from "the annotation is not the any keyword" to "there is no annotation" |
+45 |
two edits landed together and their separate contributions were not measured independently — see the next row for why that mattered |
(correction to the correction) the hand-written (...args: Array<any>) overload slot, which the parameter-predicate narrowing had re-admitted |
−24 |
excluding a rest parameter whose element type is any, with the other two spellings |
The +45 row is the one place where two changes were shipped as one number, and it cost something: the parameter-predicate half re-admitted an idiom the rule excludes under two other spellings, and nothing in the repository would have said so — there was no fixture in either direction. It was caught by review and closed by the −24 row. An implementer should take from this that the three spellings of the overload slot are a single decision, not three.
A representative true positive, from production tooling rather than a test:
const readArtifactSidecar = (sidecarPath: string): SidecarRead => {
try {
return { ok: true, sidecar: JSON.parse(readFileSync(sidecarPath, 'utf8')) };
} catch (cause) {
return { ok: false, reason: `sidecar is not valid JSON: ${String(cause)}` };
}
};
The catch covers a JSON syntax error. A sidecar that is valid JSON of the wrong shape takes the ok: true branch and is handed out as an ArtifactSidecar. That is the whole bug class in six lines, and the compiler says nothing.
False-negative analysis
Every exclusion in a soundness rule is a decision to stop reporting, and counting how far the report count fell says nothing about what fell with it. So the excluded sets were sampled and hand-vetted the same way the reports were.
One thing to note before the numbers, because it bears on how much weight they carry: the 32 private-corpus sites are byte-identical across every revision of the prototype. Every exclusion change was driven and measured on Effect-TS/effect alone. The hand-vetted 32 are evidence about the four positions; they say nothing about the exclusion set.
Private corpus, contextually-typed-parameter class: 20 of the 49 sampled at random. 0 real. Every sampled site traced to a dual or Effect.fn implementation slot carrying a defaulted parameter, and in every case the value was a poll budget, a display flag, a file mode, a fixture string or a recipe id produced in the same file. Nothing had crossed a trust boundary. Four of the twenty were the bare-Effect.fn shape with no declared overload, which is the rationale gap noted above rather than a miss.
Effect-TS/effect, assertion-carrier and arguments classes: 20 of the 120 sampled at random. 1 real. The arguments exclusion held on all ten of its sampled sites — every one an overload implementation whose declared overloads pin each arguments[n] slot, three of them additionally dispatching on a runtime type guard. The assertion carve-out held on nine of ten; the tenth was an assertion whose operand was already any, so the assertion created nothing and deferring to an assertion rule would have named the wrong expression. That finding is what produced the redundant-assertion rule in the spec above, and it is the reason this section exists: it is not visible from any count.
So: 40 excluded sites hand-vetted across two corpora, one real miss, and the general rule it produced is specified and implemented. It does not yet recover the site that motivated it: unstable/cluster/ClusterWorkflowEngine.ts:477 is still excluded on this corpus, and the reason is not yet understood — the pre-assertion operand there does not read as any in this compilation even though Rpc.Payload<any> on Entity.Request<any> suggests it should. The general discrimination is pinned by a fixture in both directions; that one residual is filed as an open question rather than as a closed finding. The remaining exclusions — rest tail and spread-bearing calls — were not sampled, because both were reworked into narrower forms rather than kept.
Where this came up
Scanning packages/effect/src produced 474 reports with the rule at error (64 / 239 / 154 / 17 across the four codes). That figure needs its scope stated rather than quoted: packages/effect/src is a runtime implementation layer that erases types internally on purpose, internal/ accounts for 119 of the 474, and Effect would obviously never enable this rule there. The scan config is inline below; the count is identical (474) under a minimal types: [] config and under the complete lib + @types/node config shown, the latter leaving exactly one unrelated TS2345, and both runs are in the same evidence directory, so the number is not an artifact of unresolved names.
Of the 474, exactly 7 carry a JSON.parse source. The other 467 fall in the generic bucket, which on this corpus is dominated by Effect's deliberate internal type erasure — that is why the 7 are the citable ones, not because the other sources are less real. (The catch-binding source produced 0 reports here and 0 on the private corpus, so one of the four sources this proposal leads with has no observation behind it in either corpus; useUnknownInCatchVariables: true explains the private zero and nothing explains Effect's.)
Six of the seven vet as true positives:
unstable/rpc/RpcSerialization.ts#L222 — const decoded: JsonRpcMessage | Array<JsonRpcMessage> = JSON.parse(...). Bytes off the wire from a JSON-RPC peer are parsed and immediately annotated as the message union with no validation; decodeJsonRpcRaw then reads .id and .method off whatever arrived.
unstable/cluster/SqlMessageStorage.ts#L1163 — values: JSON.parse(row.payload) initializing Reply.Encoded's values, declared NonEmptyReadonlyArray<unknown>. The non-empty guarantee is asserted, never checked: a stored [] satisfies the type at compile time and breaks the first indexed read. The strongest of the six, because the escaping type carries an invariant beyond its shape.
SqlMessageStorage.ts#L220 — headers: JSON.parse(row.headers!) into ReadonlyRecord<string, string>; a non-string header read back out of the database is typed string from here on.
SqlMessageStorage.ts#L1157 — exit: JSON.parse(row.payload) into ExitEncoded<unknown, unknown>; the exit's tag discriminant is not checked before it is relied on.
unstable/persistence/Persistence.ts#L431 and #L771 — values.set(row.id, JSON.parse(row.value)) into Map<string, object>. JSON.parse can return a primitive and the adjacent catch covers only syntax errors, so a stored "3" is handed back out as an object.
The seventh, unstable/encoding/Ini.ts#L57, is honestly not a latent bug: the rule is right that nothing checks the parse result against the declared string, but the surrounding isQuoted guard makes it a string in practice. Recorded, not counted.
the tsconfig the scan used
Private — an Effect v4 monorepo on effect@4.0.0-rc.112
strict: true, exactOptionalPropertyTypes: true, noUncheckedIndexedAccess: true, useUnknownInCatchVariables: true. The four checked projects compile with zero error TS diagnostics today.
32 reports, every one vetted:
| By position |
|
By package |
|
TS377140 declaration / assignment |
5 |
packages/toolplane |
20 |
TS377141 argument |
24 |
apps/daemon |
9 |
TS377142 return |
1 |
packages/assimilator |
2 |
TS377143 object literal property |
2 |
scripts |
1 |
31 of the 32 are in test or test-support files. The single non-test site is packages/toolplane/scripts/build-binary.ts:81, quoted in full above. That is the honest shape of this corpus and it cuts both ways: the one acknowledged false-positive class also lives in test code, so scoping the rule away from test files would take this corpus from 32 reports to 1. Note that a path filter on src/ would not do it — 18 of the 32 are tests colocated under src/, and the one non-test site lives under scripts/. Several of the test findings are still real — const worker: Worker = Object.create(Worker.prototype) will bite whoever refactors that test — but nobody should read a production risk estimate off these numbers.
Representative sites:
packages/toolplane/scripts/build-binary.ts:81 — the sidecar parse quoted above; the only non-test site.
apps/daemon/__tests__/ledger/digest.test.ts:8 — Schema.decodeSync(AccountDigest)(JSON.parse(raw)). decodeSync declares the Encoded type as its parameter, so handing it an any discards exactly the compile-time check that distinguishes it from decodeUnknownSync. This is the inverse of what preferTypedSchemaDecoder — the existing rule for handing a decoder an over-widened input — reports.
apps/daemon/__tests__/workspace/analysis/artifact-codec.test.ts:8 — const worker: Worker = Object.create(Worker.prototype); Object.create returns any and the annotation claims a Worker with no instance state.
packages/toolplane/src/plan/contract.environment.test.ts — 17 sites sharing one root cause: a helper ends .pipe(Effect.map((built) => JSON.parse(JSON.stringify(encode(built))))), so document is any, and each of 17 uses passes it into a Record<string, unknown> parameter.
Cross-proof that the clean run is a real clean. The same four tsconfig.json files with anyEscapesIntoTypedPosition: "error", run against a build that does not have the rule, report 0 sites and instead emit one unknownRuleName warning each for the name. The configuration is genuinely being read and the 32 are genuinely new.
Implementation notes
Provenance of every number above
Each measurement was run through a harness that writes an immutable evidence directory: a manifest (host, binary path, mtime, sha256, --version, pinned TypeScript checkout, target repo/branch/sha/dirty, every tsconfig), a summary, the full compiler output, and the exit code read after a redirect rather than through a pipe.
| Measurement |
Binary |
Prototype tree |
Target |
"compiles with zero errors" (## Bad, ## Good) |
published @effect/tsgo 0.45.0, Version 7.0.2+effect-tsgo.0.45.0 |
n/a |
throwaway project, effect@4.0.0-rc.112, strict — exit 0, 0 diagnostics |
| the same files under the prototype |
7.1.0-dev+effect-tsgo.0.45.0 |
feat/any-escapes-into-typed-position @ e23ecaa8, clean |
same project — exit 2, reports as expected |
| the 32 private-corpus reports |
same |
same, clean |
private repo @ 6d23bd7f, dirty in 7 pre-existing unrelated files, none in the scanned projects |
| private corpus clean at default severity |
same |
same, clean |
same — all four projects exit 0 |
474 Effect reports + 17 preferSchemaOverJson, both configs |
same |
same, clean |
Effect-TS/effect @ fd910d1c, clean |
| cost |
same |
same, clean |
private repo, command and load average recorded in the log |
The earlier iterations of the counts (85 → 51 → 36 → 32, 564 → 506 → 444) were taken from a dirty prototype tree during development, so they are reproducible as a progression but not as a commit. Every number quoted as current comes from the clean tree above.
Where it lives
internal/rules/any_escapes_into_typed_position.go, a rule.Rule appended to rules.All, with four message entries in internal/diagnostics/effectDiagnosticMessages.json. Group correctness, DefaultSeverity: etscore.SeverityOff, SupportedEffect: []string{"v3", "v4"}, not Effect-version gated — the only version-dependent behavior is which Schema decoder the remediation names, from ctx.TypeParser.SupportedEffectVersion().
Backbone: a rule-local walker, not ExpectedAndRealTypes
typeparser.ExpectedAndRealTypes already pairs expected with real types across eight assignment shapes and is the obvious candidate. Three things make it the wrong fit, and an implementer should know before reaching for it:
- It does not carry which shape produced a pair, and this rule needs one code per position.
- It does not expose the parameter symbol, which is needed to find the rest parameter and its element type. Its call-argument pattern also indexes
params[i] against arguments directly, so with a rest parameter the first argument is paired against any[] and every later argument is dropped.
- For two positions its
ValueNode is not the expression: for an object-literal property it is the property name, for a return it is the whole ReturnStatement. The assertion carve-out and the error range both need the value expression.
Extending it would change behavior for its existing consumer (effectInVoidSuccess), so the prototype uses a self-contained ast.Visitor walk modeled on it. If a shared typeparser surface is preferred, the right shape returns (node, valueExpression, targetType, positionKind, parameterSymbol), cached per source file with the existing Cached(&tp.links.X, sf, …) pattern.
Checker surface
All already exported, no new export required: GetContextualType, GetResolvedSignature, Signature.Parameters() / HasRestParameter(), GetTypeOfSymbolAtLocation, GetTypeOfSymbol, GetElementTypeOfArrayType, GetPromisedTypeOfPromise, GetSymbolAtLocation, GetShorthandAssignmentValueSymbol, TypeToString, plus ast.IsVarConst / ast.SkipParentheses / ast.GetContainingFunction. Type reads go through ctx.TypeParser.GetTypeAtLocation, which already carries the JSX and panic guards.
One requirement rather than a suggestion: an exported way to read a type's intrinsic name. It is what separates real any from the checker's error / unresolved / intrinsic placeholders, and the prototype reaches it through Type.AsIntrinsicType().IntrinsicName(), available only because checker.Type is a shim type alias. AsIntrinsicType() panics rather than reporting a mismatch, so the prototype wraps it in recover() — and that is a fail-silent path worth naming out loud: on a panic the helper returns "", the type reads as not-any, and the rule stops reporting with every baseline still green. A soundness check whose failure is indistinguishable from "nothing found" is the one failure mode a lint rule must not have. A small exported accessor removes both the alias dependency and the recover.
Two smaller specification points an implementer will hit: for a non-assignment binary operator or a conditional, the origin walk requires at least one any operand and that every any operand be excluded (so jsonAny || (x as any) still reports); and when GetResolvedSignature fails outright and returns a placeholder signature, the argument position should be skipped rather than checked against whatever parameter types the placeholder carries. Message length is left to the compiler's default truncation; the private corpus produced targets long enough to matter ({ readonly accountId: string; readonly capturedAtMs: number | null; … }).
Fixtures
Six, in the established layout, all with generated baselines and zero error TS in every one:
effect-v4/anyEscapesIntoTypedPosition.ts — all four positions, every listed negative, the rest-element, spread and narrowed-assignment-target cases (15 reports).
effect-v4/anyEscapesIntoTypedPosition_untypedSources.ts — carries its own tsconfig.json with useUnknownInCatchVariables: false and a declare module shim, covering the implicit-catch and untyped-module sources (3 reports).
effect-v4/anyEscapesIntoTypedPosition_excludedOrigins.ts — the exclusion cases with seven positive controls, including the redundant-assertion, any-property-on-concrete-base, rest-of-any-slot and alias-of-any discriminations, so none of them can regress silently (7 reports).
effect-v3/anyEscapesIntoTypedPosition.ts — pins the v3 decoder spelling (6 reports).
effect-v4/anyEscapesIntoTypedPosition_preview.ts — the docs preview (2 reports).
effect-v4/anyEscapesIntoTypedPosition_realDual.ts — the real dual from effect/Function, not a synthetic stand-in, so the exclusion cannot break silently if that signature changes. It carries const probe: string = quantity, which compiles only because the parameter is any, so the fixture pins the premise as well as the behaviour: 0 reports, 0 error TS.
Three of the six prototype behaviours had no distinguishing control until review caught it, and all three now do: the hand-written rest-of-any slot (silent) against an ordinary any[] parameter (reported); a parameter annotated through type Loose = any (reported); and an assignment to a target narrowed away from its declaration, whose message must name User | null rather than the narrowing. Each would go red if the code it guards were reverted.
Cost
One pre-order AST walk per source file, one GetResolvedSignature per call expression, and TypeToString only on the rare any hits. Measured on the private monorepo, status read after a redirect:
| Project |
rule at error |
rule at default (off) |
delta |
packages/toolplane (n=10 each) |
median 1057 ms (1000–1111) |
median 1042 ms (1001–1083) |
+16 ms, +1.5% |
apps/daemon (n=8 each) |
median 4078 ms (3985–4597) |
median 3966 ms (3884–4491) |
+112 ms, +2.8% |
A small, consistent ~2% rather than the "indistinguishable from noise" that a 3-run sample first suggested. Neither arm was measured on an idle machine: the 1-minute load average was 8–12 on a 10-core host, recorded in the log at the start and end of each set. Both arms shared the condition and the medians are stable across the runs, but the absolute figures are not idle-machine numbers. An earlier set taken at load ~23 gave +20 ms / +92 ms, so the ~2% is stable across two very different load conditions.
Incremental pnpm build after touching only the rule file, 3 runs from a clean tree: 8802 / 6952 / 5742 ms, within this repo's usual 4–6 s incremental range at the low end and above it at the high end, on the same non-idle host.
Validation
pnpm lint exit 0, pnpm check exit 0, pnpm test at the macOS baseline — Go 20 packages ok and 0 FAIL; vitest 3 failed / 122 passed, the three being the pre-existing /var vs /private/var realpath failures in _packages/tsgo/test/experimental-oxlint.test.ts. The generated README table, metadata.json, docs/rules/, schema.json, oxlint-schema.json and oxlint-presets/ were regenerated and are consistent in CI mode.
Who would turn this on
Worth answering rather than leaving implied, given 474 reports on the proposing project's own source. The rule is correctness-group and off by default, like the two existing soundness rules. The adopters it is built for are application code, especially at a decode boundary, with the rule scoped away from an implementation layer that erases types on purpose and away from test code, where the one known false-positive class lives. For an existing codebase with hundreds of hits there is no baseline or suppression story in v1 beyond @effect-diagnostics directives, and that is a real adoption gap, not a detail.
Proposed rule name
anyEscapesIntoTypedPosition
Problem
TypeScript never reports an
anyflowing into a typed position, and the reason is a single rule in the assignability relation:anyis assignable to every type exceptnever, and every type is assignable toany. Assignability is the only check that constrains the source type when a value reaches a declaration, a parameter, areturn, or a contextually typed property — object-literal freshness andexactOptionalPropertyTypesconstrain a literal's shape, not its assignability fromany. So when the source type isany, that check succeeds against every target that matters, and it is not weak or heuristic, it is vacuous. No strictness flag changes it:strict,exactOptionalPropertyTypesandnoUncheckedIndexedAccessleave it untouched, because none of them is about assignability fromany.noImplicitAnyis the flag people reach for, and it does not cover this.noImplicitAnygoverns where a declaration may be left implicitlyany— a parameter with no annotation, a variable the checker cannot infer. It says nothing about where ananythat already exists is allowed to flow.JSON.parseis declared to returnanyinlib.es5.d.ts; thatanyis explicit in the standard library, sonoImplicitAnyhas no opinion about it, and neither does any other flag. The same holds forrequire(), for a shorthand ambient module (declare module "x"), for acatchbinding underuseUnknownInCatchVariables: false, and for anany-typed property on a hand-written binding declaration.What this costs at runtime: the moment an
anyis annotated as a concrete type, every downstream read of that value is checked against a shape nobody verified.const user: User = JSON.parse(raw)compiles with zero errors, anduser.profile.emailthrowsCannot read properties of undefinedin production against a payload that changed shape. The value is not merely unvalidated — it is laundered: from that binding onward the compiler tells every reader, and every editor hover, that it is aUser, so the one place a reviewer might have asked "was this checked?" is the place the type says there is nothing to ask. The bug surfaces arbitrarily far from the parse, and the parse is the last place anyone looks.How this relates to
@typescript-eslint/no-unsafe-*This is the obvious prior art and it should be addressed first.
@typescript-eslintshipsno-unsafe-assignment,no-unsafe-argument,no-unsafe-returnandno-unsafe-member-access; they are type-aware, they are in thestrict-type-checkedpreset, and the first three are positions 1+4, 2 and 3 of this proposal under different names. This proposal is that family plus Effect-aware origin analysis, Effect-aware remediation, and per-position codes. Concretely, four things here are not inno-unsafe-*:dualandEffect.fnaccept their implementation through a(...args: Array<any>) => anyslot, which types every implementation parameteranywhatever the author wrote. Measured below: 49 of the first 85 reports on a real Effect codebase — 58% — were this one class. A rule without that exclusion is not usable on Effect code, andno-unsafe-*does not have it.arguments-object origin exclusion, which is the same idiom spelled througharguments[0]: a further 62 reports onEffect-TS/effect.anyback through a conditional, a binary operator, an access chain and an unannotatedconstbinding, so anas anycarried outward is attributed to the assertion rather than to the site that merely relays it — and, symmetrically, so an assertion that launders nothing is not attributed to the assertion.Schema.decodeUnknownSync/Schema.decodeUnknownEffectfor aJSON.parsesource,unknownplus a guard for acatchbinding — and one diagnostic code per position, whichno-unsafe-assignmentcannot express because it merges the declaration and object-literal cases.If the maintainers would rather point users at
no-unsafe-*and ship only the Effect-specific parts, that is a legitimate outcome, and the exclusions above are the parts worth keeping.How this relates to
preferSchemaOverJsonpreferSchemaOverJsonis syntactic and Effect-scoped: it matchesJSON.parse/JSON.stringifycall sites insideEffect.tryor anEffect.gen/Effect.fnbody. It is a good rule and this one does not replace it. They answer different questions — one asks "is this call spelled the Effect way?", the other asks "did an unvalidated value just acquire a type it never earned?" — and the measurement says so plainly. OnEffect-TS/effect'spackages/effect/src, on the same corpus at the same commit under the same tsconfig, each rule aterrorin its own run:preferSchemaOverJsonreports 17 diagnostics across 15 distinct lines, this rule reports 474, and the two share zero file-and-line positions. On the private corpus below,preferSchemaOverJsonreports 0 and this rule reports 32. Neither rule subsumes the other on either corpus.This sits beside the two existing soundness rules —
unsafeEffectTypeAssertionandanyUnknownInErrorContext, the only twocorrectness-group rules that default tooff— as acorrectness-group, default-off, opt-in diagnostic.Bad — compiles cleanly, the rule should flag this
Every block below was compiled in a throwaway project with
@effect/tsgo0.45.0 (tscreportingVersion 7.0.2+effect-tsgo.0.45.0aftereffect-tsgo patch --typescript) againsteffect@4.0.0-rc.112under"strict": true, and exits 0 with zero diagnostics. Nothing here is a type error today.The third source is a module with no types. It takes two files, so here they are separately.
Good
Proposed rule behavior
A type-aware rule. For each expression whose checker type is
any, decide whether it sits in one of four positions with a concrete target type, and if so report once, at the expression.Positions, each with its own diagnostic code
One code per position so a reader can tell the flow apart from the diagnostic alone.
{0}is the target type,{1}is a source-directed remediation:TS377140=assignment whose target has a concrete typeThis `any` value is assigned to a target typed `{0}`. TypeScript accepts it silently because an assignability check against a source of type `any` succeeds against this target. {1}TS377141newexpressionThis `any` value is passed to a parameter typed `{0}`. …TS377142returnin a function with a declared non-anyreturn type, plus the expression body of an arrow with a declared or contextual return typeThis `any` value is returned from a function declared to return `{0}`. …TS377143This `any` value initializes a property whose contextual type is `{0}`. …The wording deliberately does not say "because
anyis assignable to every type". That is false —anyis not assignable tonever— and this rule's ownnevertarget exclusion depends on the exception.The remediation names the fix for the source that was found:
JSON.parsesource:`JSON.parse` returns `any`: decode the result with `Schema.decodeUnknownSync(schema)` or `Schema.decodeUnknownEffect(schema)` instead.— on v3,Schema.decodeUnknown(schema)in place ofdecodeUnknownEffect.catchbinding source:This value comes from a `catch` binding: type it `unknown` (enable `useUnknownInCatchVariables`) and narrow it with a type guard or `Schema.is(schema)` before using it.Annotate the source as `unknown` and narrow it, or decode it with Schema.Reading the target. For an annotated declaration the target is read off the annotation's type node, not off the declared name, so a narrowed name type can never stand in for the declared one. For an
=assignment the same rule applies, via the target symbol's declared type rather than its control-flow-narrowed type at the assignment — otherwiselet u: User | null = null; if (c) { u = jsonAny }reports against the narrowing instead of againstUser | null. When the target is not a simple reference (an element access, a destructuring pattern) and no symbol resolves, fall back to the type at the location. For anasyncfunction declaredPromise<T>the target isTviaGetPromisedTypeOfPromise, so the message names the type the author meant. Compound assignment (+=,??=,||=) is out of scope for v1 —+=genuinely reads the target as well as writing it, and the logical forms were not separated out; that is a decision, not an oversight, and the logical-assignment operators are where it is least defensible.Excluded targets
A target is concrete only when it is none of these:
any— the destination makes no claim, so nothing is laundered.unknown— this is the shape the rule exists to steer people toward, and reporting it would punish the fix.void— the value is discarded.never—anyis the one type not assignable tonever, so TypeScript already errors; reporting would duplicate it.anyparameter needs no special case: the resolved signature's parameter type isany, so the any-target exclusion already covers it.A rest parameter is NOT excluded. An argument in the rest tail is checked against the rest parameter's element type. This matters because the obvious shortcut — skip the rest tail so
console.logstays quiet — is both unnecessary and lossy.console.logis declaredlog(...data: any[]), so its element type isanyand the any-target exclusion silences it with no rest-specific rule at all; meanwhilefunction record(...events: DomainEvent[])has a concrete element type and ananyreaching it is a real escape. Measured: switching from a blanket skip to element extraction changed the private corpus by 0 and added 7 reports onEffect-TS/effect— sixArray.prototype.pushand oneMath.max(...values: number[]), which is the better example, because nobody expects a logging-shaped variadic there. A call carrying a spread argument stops the scan at the spread; arguments before it are still positionally aligned and are still checked. A tuple rest parameter (...args: [a: string, b: number]) has no single element type and is out of scope for v1: the prototype's element lookup returns nothing and the argument is skipped.The assertion carve-out, and the rule it depends on
An expression that is itself a type assertion —
as T,<T>expr,satisfies T,as const— is not reported here.State the dependency plainly: no rule in this repository ships coverage of
as anyinto a concrete typed position today.unsafeEffectTypeAssertiononly detects assertions that narrow an Effect, Stream or Layer error or requirements channel; it does not fire onconst user: User = raw as any. A companion proposal for an explicit-assertion rule is being prepared alongside this one and would cover exactly that shape; it should be read as this proposal's other half, and if it does not ship, this rule should own the case at a fifth diagnostic code rather than leave it uncovered. (typescript/no-unsafe-type-assertionalso already appears in this repository's own generatedoxlint-schema.json, alongside the rest of theno-unsafe-*family, which is worth knowing when weighing whether a separate rule is wanted at all.)Sizing this honestly: extending the carve-out to carrier forms removed 58 reports here, but 58 is what the carve-out removed, not the size of the class it declines to own — the underlying population of explicit
as anyassertions on this corpus is far larger. It is still the single largest class the proposal declines to own.The carve-out also has to go one step further than the syntactic check, in both directions, and this is where a naive implementation leaks:
In all three the reported expression is not an assertion but the
anyis the assertion's, so the walk must follow the forms that carry ananywithout producing one — a conditional, a non-assignment binary operator, an access chain,await, a non-null assertion, and an unannotatedconstbinding. Measured on the Effect repo, adding this cut 564 reports to 506.The converse matters just as much. An assertion whose operand is already
anylaunders nothing — deleting it would not change the escape — so the real origin lies upstream and an assertion-focused rule would report the wrong expression and suggest the wrong fix.box.payload as anywherebox: Box<any>must still be reported;box.payload as anywherebox: Box<string>must not. This was found by hand-vetting the excluded set (below), not by watching a count, and it is the difference between a carve-out and a hole.Excluded origins
Three origins are
anywithout being a value entering the program — and the first three below are one idiom in three spellings, which is the single most important thing in this section. All must be excluded or the rule is unusable on Effect code, and each is stated here in the broad form that is actually implementable, with its costs, rather than in a narrower form that sounds better.An UNANNOTATED parameter. Effect's
dualandEffect.fnaccept their implementation through a slot shaped(...args: Array<any>) => any, and a NodeEventEmitterlistener arrives through(...args: any[]) => void. A parameter in such a slot is typedanywhatever the author wrote, because the contextual type wins over an initializer:Only an unannotated parameter qualifies: an explicit annotation always wins over the contextual type, so an annotated parameter that is
anywas annotatedany— including via an alias,type Loose = any, which means the predicate must resolve the annotation's type rather than match theanykeyword — and that is the author's own claim and is reported.Under
noImplicitAny: falsethis exclusion absorbs the entire implicit-anyparameter class, not just the contextual-slot class it was written for, because every unannotated parameter is thenanyand unannotated. That is a much larger set. Understrictthe language itself covers it (TS7006), so the exclusion only widens wherenoImplicitAnyis already off.The cost of stating it this broadly, measured and disclosed: a callback parameter whose type a
.d.tsdeclaresanyis also unannotated at the call site, and it is also excluded — even though it is an author-declared untyped boundary rather than an implementation slot. The asymmetry is real and reproducible:Same declared-
anyboundary, same value, two arrival routes, one reported. The principled narrowing — exclude only when the parameter's index falls in the contextual signature's rest tail with ananyelement — was implemented and measured, and it does not work:GetContextualTypedoes not resolvedual's generic slot (F extends (...args: Array<any>) => any), so thedualexclusion collapsed and the private corpus went from 32 reports back to 86. NeitherGetBaseConstraintOfTypeon the contextual type nor falling back to the enclosing call's parameter type recovered it. An implementer who wants the narrow form needs a checker surface that resolves a generic implementation slot's constraint signature; that surface is the blocker, not the idea. Until then the broad form is what ships, and the false negative above is a known, stated limitation.One further correction to the usual justification: the exclusion is often defended as "what reaches the parameter is constrained by the public overload". For
dualthat is true. For a bareEffect.fn(function* (recipeId = DEFAULT) {…})there is no declared overload, and only the in-file call sites constrain the parameter. Four sites in the vetted sample were of that kind. They were still not bugs, but the rationale is narrower than the exclusion, and an implementer should know which part is load-bearing.A REST parameter whose element type is
any. This is the hand-written spelling of the same slot, and excluding it is not optional — it is required for consistency:argsis annotatedArray<any>, notany, so a predicate that only asks "is the annotationany?" reports this while excluding thedual-contextual spelling and thearguments[0]spelling of the identical dispatch. This was a live regression in the prototype: reporting it cost 24 reports onEffect-TS/effect— 14 inTxSemaphore, 8 inTxReentrantLock, 2 inSchema— before the rest-of-anycase was excluded with the other two. Three spellings of one idiom must be decided together.A read off the implicit
argumentsobject, which declares[index: number]: any. Effect's overload implementations forward througharguments[0]exactly as they forward through a contextually typed parameter. This was a further 62 reports on the Effect repo. The mechanism is specific: an element access whose base is an identifier spelledargumentsthat resolves to no declaration; a user binding namedargumentsresolves and is unaffected.Attribution rule that all of these depend on. An origin walk through an access chain must consult the base only when the base is itself
any. If the base carries a concrete type then the access produced theany—request.payloadwhererequest: Entity.Request<any>, or anany-typed property on a binding declaration — and the base's origin says nothing about it. Getting this wrong silently discards the "any-typed property" source the rule claims to cover. Fixing it, bundled with the parameter-predicate narrowing above, recovered 45 reports onEffect-TS/effect; see the correction table below for why bundling those two was a mistake.One diagnostic per escape site
Report at the expression, once, never once per downstream use.
anybound to a name and used at many typed positions produces one diagnostic per use. That is correct — each is a distinct escape — but a small number of upstreamanys can dominate a report. It happened here: 17 of the 32 private-corpus sites trace to three copies of one helper shape across nineconst document = yield* …bindings, so the corpus has 18 distinctany-producing expressions behind 32 reports.(start, code).Out of scope for v1
constback to its initializer, which is alias resolution rather than flow analysis: aconstwith no annotation is a name for its initializer. Cap the chain — the prototype uses 4 hops — and state the direction of the cap: past it the prototype answers "not excluded", so it reports. That trades a possible false positive on a five-hop alias chain for never silently dropping an escape, which is the right direction for a soundness check, but it is a decision and not an implementation detail.yield/yield*operands, and areturninside a generator: the declared type describes the generator, not the value.returnin a block body whose return type comes only from the contextual signature.const f: () => User = () => JSON.parse(raw)reports;const f: () => User = () => { return JSON.parse(raw) }does not, because the block-bodied path uses the declared annotation only. Two spellings of the same code, one reported — this will be filed as a bug on day one, and extending the contextual fallback to the block-bodied path is the first thing to fix after v1.anyflag but whose intrinsic name iserror(likewiseunresolvedandintrinsic). Checking the flag alone reports every unresolved expression as an escape. Printing the type does not discriminate either — the error type prints asany. Only the intrinsic name separates them. This produced a live false positive (error.messagewhereerror: unknown) before it was fixed.False-positive analysis
The criterion, stated before it was applied: a report is a false positive when the
anyoriginates from a declaration whose purpose is to type-erase a value the program never reads through the claimed type. Ananythat is read through the type it was given is a true positive whether or not the author knew.Vetted all 32 reports on the private corpus below — not a sample — across 4 packages and all 4 codes. 29 true positives, 3 false positives: a 90.6% true-positive rate, or 15 of 18 counting by distinct
any-producing expression.The three false positives are one class: an asymmetric test matcher.
expect.arrayContaining([...])andexpect.any(Number)are declared to returnanyso they can stand in a typed slot, and the matcher protocol — not the parameter's declared type — is what reads them. They fail the criterion exactly. The class is not excluded by the spec above, is offered as a known limitation, and disappears if a consumer scopes the rule away from test code.Two reports that the criterion keeps as true positives, because a reader might expect otherwise:
Object.create(Worker.prototype)annotatedWorker, andObject.fromEntries(...)resolving to itsany-returning overload. Both areany-returning by declaration, but in both cases the value is subsequently read through the claimed type, so they are laundering, not erasure.The false-positive classes that were found and that the spec above excludes, with the measured counts. The two corpora are kept separate because they have different denominators:
Private corpus — the measured progression is
85 → 51 → 36 → 32:anyby a(...args: Array<any>) => anyslotGetShorthandAssignmentValueSymbol— the ordinary lookup answers with the property the literal declares, not the variable supplying its valueconst next = depth + 1or a conditionalSo the contextually-typed-parameter class is 49 of 85 — 58%, not the 34 that the first fix alone removed. It is by a wide margin the most important thing in this proposal.
Effect-TS/effect—564 → 506 → 444, then corrections back up and down to474:as anycarried outward by a conditional or an access chainarguments[0]in an overload implementationarguments-object origin exclusionanyanyproduced by an access on a concrete base, wrongly attributed to the base, bundled with narrowing the parameter predicate from "the annotation is not theanykeyword" to "there is no annotation"(...args: Array<any>)overload slot, which the parameter-predicate narrowing had re-admittedany, with the other two spellingsThe
+45row is the one place where two changes were shipped as one number, and it cost something: the parameter-predicate half re-admitted an idiom the rule excludes under two other spellings, and nothing in the repository would have said so — there was no fixture in either direction. It was caught by review and closed by the−24row. An implementer should take from this that the three spellings of the overload slot are a single decision, not three.A representative true positive, from production tooling rather than a test:
The
catchcovers a JSON syntax error. A sidecar that is valid JSON of the wrong shape takes theok: truebranch and is handed out as anArtifactSidecar. That is the whole bug class in six lines, and the compiler says nothing.False-negative analysis
Every exclusion in a soundness rule is a decision to stop reporting, and counting how far the report count fell says nothing about what fell with it. So the excluded sets were sampled and hand-vetted the same way the reports were.
One thing to note before the numbers, because it bears on how much weight they carry: the 32 private-corpus sites are byte-identical across every revision of the prototype. Every exclusion change was driven and measured on
Effect-TS/effectalone. The hand-vetted 32 are evidence about the four positions; they say nothing about the exclusion set.Private corpus, contextually-typed-parameter class: 20 of the 49 sampled at random. 0 real. Every sampled site traced to a
dualorEffect.fnimplementation slot carrying a defaulted parameter, and in every case the value was a poll budget, a display flag, a file mode, a fixture string or a recipe id produced in the same file. Nothing had crossed a trust boundary. Four of the twenty were the bare-Effect.fnshape with no declared overload, which is the rationale gap noted above rather than a miss.Effect-TS/effect, assertion-carrier andargumentsclasses: 20 of the 120 sampled at random. 1 real. Theargumentsexclusion held on all ten of its sampled sites — every one an overload implementation whose declared overloads pin eacharguments[n]slot, three of them additionally dispatching on a runtime type guard. The assertion carve-out held on nine of ten; the tenth was an assertion whose operand was alreadyany, so the assertion created nothing and deferring to an assertion rule would have named the wrong expression. That finding is what produced the redundant-assertion rule in the spec above, and it is the reason this section exists: it is not visible from any count.So: 40 excluded sites hand-vetted across two corpora, one real miss, and the general rule it produced is specified and implemented. It does not yet recover the site that motivated it:
unstable/cluster/ClusterWorkflowEngine.ts:477is still excluded on this corpus, and the reason is not yet understood — the pre-assertion operand there does not read asanyin this compilation even thoughRpc.Payload<any>onEntity.Request<any>suggests it should. The general discrimination is pinned by a fixture in both directions; that one residual is filed as an open question rather than as a closed finding. The remaining exclusions — rest tail and spread-bearing calls — were not sampled, because both were reworked into narrower forms rather than kept.Where this came up
Public — Effect-TS/effect at
fd910d1cScanning
packages/effect/srcproduced 474 reports with the rule aterror(64 / 239 / 154 / 17 across the four codes). That figure needs its scope stated rather than quoted:packages/effect/srcis a runtime implementation layer that erases types internally on purpose,internal/accounts for 119 of the 474, and Effect would obviously never enable this rule there. The scan config is inline below; the count is identical (474) under a minimaltypes: []config and under the completelib+@types/nodeconfig shown, the latter leaving exactly one unrelatedTS2345, and both runs are in the same evidence directory, so the number is not an artifact of unresolved names.Of the 474, exactly 7 carry a
JSON.parsesource. The other 467 fall in the generic bucket, which on this corpus is dominated by Effect's deliberate internal type erasure — that is why the 7 are the citable ones, not because the other sources are less real. (Thecatch-binding source produced 0 reports here and 0 on the private corpus, so one of the four sources this proposal leads with has no observation behind it in either corpus;useUnknownInCatchVariables: trueexplains the private zero and nothing explains Effect's.)Six of the seven vet as true positives:
unstable/rpc/RpcSerialization.ts#L222—const decoded: JsonRpcMessage | Array<JsonRpcMessage> = JSON.parse(...). Bytes off the wire from a JSON-RPC peer are parsed and immediately annotated as the message union with no validation;decodeJsonRpcRawthen reads.idand.methodoff whatever arrived.unstable/cluster/SqlMessageStorage.ts#L1163—values: JSON.parse(row.payload)initializingReply.Encoded'svalues, declaredNonEmptyReadonlyArray<unknown>. The non-empty guarantee is asserted, never checked: a stored[]satisfies the type at compile time and breaks the first indexed read. The strongest of the six, because the escaping type carries an invariant beyond its shape.SqlMessageStorage.ts#L220—headers: JSON.parse(row.headers!)intoReadonlyRecord<string, string>; a non-string header read back out of the database is typedstringfrom here on.SqlMessageStorage.ts#L1157—exit: JSON.parse(row.payload)intoExitEncoded<unknown, unknown>; the exit's tag discriminant is not checked before it is relied on.unstable/persistence/Persistence.ts#L431and#L771—values.set(row.id, JSON.parse(row.value))intoMap<string, object>.JSON.parsecan return a primitive and the adjacentcatchcovers only syntax errors, so a stored"3"is handed back out as anobject.The seventh,
unstable/encoding/Ini.ts#L57, is honestly not a latent bug: the rule is right that nothing checks the parse result against the declaredstring, but the surroundingisQuotedguard makes it a string in practice. Recorded, not counted.the tsconfig the scan used
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "nodenext", "moduleDetection": "force", "verbatimModuleSyntax": true, "allowJs": false, "rewriteRelativeImportExtensions": true, "erasableSyntaxOnly": true, "strict": true, "exactOptionalPropertyTypes": true, "noImplicitOverride": true, "noFallthroughCasesInSwitch": true, "skipLibCheck": true, "noErrorTruncation": true, "noEmit": true, "jsx": "react-jsx", "lib": ["ESNext", "ESNext.Disposable", "DOM", "DOM.Iterable"], "types": ["node"], "paths": { "effect": ["<repo>/packages/effect/src/index.ts"], "effect/*": ["<repo>/packages/effect/src/*"] }, "plugins": [{ "name": "@effect/language-service", "diagnosticSeverity": { "anyEscapesIntoTypedPosition": "error" } }] }, "include": ["<repo>/packages/effect/src/**/*.ts"] }Private — an Effect v4 monorepo on
effect@4.0.0-rc.112strict: true,exactOptionalPropertyTypes: true,noUncheckedIndexedAccess: true,useUnknownInCatchVariables: true. The four checked projects compile with zeroerror TSdiagnostics today.32 reports, every one vetted:
TS377140declaration / assignmentpackages/toolplaneTS377141argumentapps/daemonTS377142returnpackages/assimilatorTS377143object literal propertyscripts31 of the 32 are in test or test-support files. The single non-test site is
packages/toolplane/scripts/build-binary.ts:81, quoted in full above. That is the honest shape of this corpus and it cuts both ways: the one acknowledged false-positive class also lives in test code, so scoping the rule away from test files would take this corpus from 32 reports to 1. Note that a path filter onsrc/would not do it — 18 of the 32 are tests colocated undersrc/, and the one non-test site lives underscripts/. Several of the test findings are still real —const worker: Worker = Object.create(Worker.prototype)will bite whoever refactors that test — but nobody should read a production risk estimate off these numbers.Representative sites:
packages/toolplane/scripts/build-binary.ts:81— the sidecar parse quoted above; the only non-test site.apps/daemon/__tests__/ledger/digest.test.ts:8—Schema.decodeSync(AccountDigest)(JSON.parse(raw)).decodeSyncdeclares the Encoded type as its parameter, so handing it ananydiscards exactly the compile-time check that distinguishes it fromdecodeUnknownSync. This is the inverse of whatpreferTypedSchemaDecoder— the existing rule for handing a decoder an over-widened input — reports.apps/daemon/__tests__/workspace/analysis/artifact-codec.test.ts:8—const worker: Worker = Object.create(Worker.prototype);Object.createreturnsanyand the annotation claims aWorkerwith no instance state.packages/toolplane/src/plan/contract.environment.test.ts— 17 sites sharing one root cause: a helper ends.pipe(Effect.map((built) => JSON.parse(JSON.stringify(encode(built))))), sodocumentisany, and each of 17 uses passes it into aRecord<string, unknown>parameter.Cross-proof that the clean run is a real clean. The same four
tsconfig.jsonfiles withanyEscapesIntoTypedPosition: "error", run against a build that does not have the rule, report 0 sites and instead emit oneunknownRuleNamewarning each for the name. The configuration is genuinely being read and the 32 are genuinely new.Implementation notes
Provenance of every number above
Each measurement was run through a harness that writes an immutable evidence directory: a manifest (host, binary path, mtime, sha256,
--version, pinned TypeScript checkout, target repo/branch/sha/dirty, every tsconfig), a summary, the full compiler output, and the exit code read after a redirect rather than through a pipe.## Bad,## Good)@effect/tsgo0.45.0,Version 7.0.2+effect-tsgo.0.45.0effect@4.0.0-rc.112,strict— exit 0, 0 diagnostics7.1.0-dev+effect-tsgo.0.45.0feat/any-escapes-into-typed-position@e23ecaa8, clean6d23bd7f, dirty in 7 pre-existing unrelated files, none in the scanned projectspreferSchemaOverJson, both configsEffect-TS/effect@fd910d1c, cleanThe earlier iterations of the counts (
85 → 51 → 36 → 32,564 → 506 → 444) were taken from a dirty prototype tree during development, so they are reproducible as a progression but not as a commit. Every number quoted as current comes from the clean tree above.Where it lives
internal/rules/any_escapes_into_typed_position.go, arule.Ruleappended torules.All, with four message entries ininternal/diagnostics/effectDiagnosticMessages.json. Groupcorrectness,DefaultSeverity: etscore.SeverityOff,SupportedEffect: []string{"v3", "v4"}, not Effect-version gated — the only version-dependent behavior is which Schema decoder the remediation names, fromctx.TypeParser.SupportedEffectVersion().Backbone: a rule-local walker, not
ExpectedAndRealTypestypeparser.ExpectedAndRealTypesalready pairs expected with real types across eight assignment shapes and is the obvious candidate. Three things make it the wrong fit, and an implementer should know before reaching for it:params[i]against arguments directly, so with a rest parameter the first argument is paired againstany[]and every later argument is dropped.ValueNodeis not the expression: for an object-literal property it is the property name, for areturnit is the whole ReturnStatement. The assertion carve-out and the error range both need the value expression.Extending it would change behavior for its existing consumer (
effectInVoidSuccess), so the prototype uses a self-containedast.Visitorwalk modeled on it. If a shared typeparser surface is preferred, the right shape returns(node, valueExpression, targetType, positionKind, parameterSymbol), cached per source file with the existingCached(&tp.links.X, sf, …)pattern.Checker surface
All already exported, no new export required:
GetContextualType,GetResolvedSignature,Signature.Parameters()/HasRestParameter(),GetTypeOfSymbolAtLocation,GetTypeOfSymbol,GetElementTypeOfArrayType,GetPromisedTypeOfPromise,GetSymbolAtLocation,GetShorthandAssignmentValueSymbol,TypeToString, plusast.IsVarConst/ast.SkipParentheses/ast.GetContainingFunction. Type reads go throughctx.TypeParser.GetTypeAtLocation, which already carries the JSX and panic guards.One requirement rather than a suggestion: an exported way to read a type's intrinsic name. It is what separates real
anyfrom the checker'serror/unresolved/intrinsicplaceholders, and the prototype reaches it throughType.AsIntrinsicType().IntrinsicName(), available only becausechecker.Typeis a shim type alias.AsIntrinsicType()panics rather than reporting a mismatch, so the prototype wraps it inrecover()— and that is a fail-silent path worth naming out loud: on a panic the helper returns"", the type reads as not-any, and the rule stops reporting with every baseline still green. A soundness check whose failure is indistinguishable from "nothing found" is the one failure mode a lint rule must not have. A small exported accessor removes both the alias dependency and the recover.Two smaller specification points an implementer will hit: for a non-assignment binary operator or a conditional, the origin walk requires at least one
anyoperand and that everyanyoperand be excluded (sojsonAny || (x as any)still reports); and whenGetResolvedSignaturefails outright and returns a placeholder signature, the argument position should be skipped rather than checked against whatever parameter types the placeholder carries. Message length is left to the compiler's default truncation; the private corpus produced targets long enough to matter ({ readonly accountId: string; readonly capturedAtMs: number | null; … }).Fixtures
Six, in the established layout, all with generated baselines and zero
error TSin every one:effect-v4/anyEscapesIntoTypedPosition.ts— all four positions, every listed negative, the rest-element, spread and narrowed-assignment-target cases (15 reports).effect-v4/anyEscapesIntoTypedPosition_untypedSources.ts— carries its owntsconfig.jsonwithuseUnknownInCatchVariables: falseand adeclare moduleshim, covering the implicit-catch and untyped-module sources (3 reports).effect-v4/anyEscapesIntoTypedPosition_excludedOrigins.ts— the exclusion cases with seven positive controls, including the redundant-assertion,any-property-on-concrete-base, rest-of-any-slot and alias-of-anydiscriminations, so none of them can regress silently (7 reports).effect-v3/anyEscapesIntoTypedPosition.ts— pins the v3 decoder spelling (6 reports).effect-v4/anyEscapesIntoTypedPosition_preview.ts— the docs preview (2 reports).effect-v4/anyEscapesIntoTypedPosition_realDual.ts— the realdualfromeffect/Function, not a synthetic stand-in, so the exclusion cannot break silently if that signature changes. It carriesconst probe: string = quantity, which compiles only because the parameter isany, so the fixture pins the premise as well as the behaviour: 0 reports, 0error TS.Three of the six prototype behaviours had no distinguishing control until review caught it, and all three now do: the hand-written rest-of-
anyslot (silent) against an ordinaryany[]parameter (reported); a parameter annotated throughtype Loose = any(reported); and an assignment to a target narrowed away from its declaration, whose message must nameUser | nullrather than the narrowing. Each would go red if the code it guards were reverted.Cost
One pre-order AST walk per source file, one
GetResolvedSignatureper call expression, andTypeToStringonly on the rareanyhits. Measured on the private monorepo, status read after a redirect:erroroff)packages/toolplane(n=10 each)apps/daemon(n=8 each)A small, consistent ~2% rather than the "indistinguishable from noise" that a 3-run sample first suggested. Neither arm was measured on an idle machine: the 1-minute load average was 8–12 on a 10-core host, recorded in the log at the start and end of each set. Both arms shared the condition and the medians are stable across the runs, but the absolute figures are not idle-machine numbers. An earlier set taken at load ~23 gave +20 ms / +92 ms, so the ~2% is stable across two very different load conditions.
Incremental
pnpm buildafter touching only the rule file, 3 runs from a clean tree: 8802 / 6952 / 5742 ms, within this repo's usual 4–6 s incremental range at the low end and above it at the high end, on the same non-idle host.Validation
pnpm lintexit 0,pnpm checkexit 0,pnpm testat the macOS baseline — Go 20 packagesokand 0 FAIL; vitest 3 failed / 122 passed, the three being the pre-existing/varvs/private/varrealpath failures in_packages/tsgo/test/experimental-oxlint.test.ts. The generated README table,metadata.json,docs/rules/,schema.json,oxlint-schema.jsonandoxlint-presets/were regenerated and are consistent in CI mode.Who would turn this on
Worth answering rather than leaving implied, given 474 reports on the proposing project's own source. The rule is
correctness-group andoffby default, like the two existing soundness rules. The adopters it is built for are application code, especially at a decode boundary, with the rule scoped away from an implementation layer that erases types on purpose and away from test code, where the one known false-positive class lives. For an existing codebase with hundreds of hits there is no baseline or suppression story in v1 beyond@effect-diagnosticsdirectives, and that is a real adoption gap, not a detail.Proposed rule name
anyEscapesIntoTypedPosition