Repository navigation
Bug: [no-unnecessary-type-assertion] non-deterministic false positive whose autofix breaks the build #1141
Description
Summary
When a generic call's type parameter appears in none of the parameters, TypeScript infers the type argument from a following type assertion. The operand then has exactly the asserted type, the rule reports the assertion as unnecessary, and the autofix removes it — which drops the type argument back to its default and stops the code compiling.
getUncastType already guards this with Checker_getContextFreeTypeOfExpression (#824, for oxc-project/oxc#20656), but the checker caches a call's resolved signature per node, so the guard only holds while nothing else has checked that call. Which calls have been checked depends on concurrent scheduling, so the same unchanged input produces a different set of diagnostics from run to run.
Reproduction
interface Base { id: string }
interface Derived extends Base { extra: number }
// `T` appears in no parameter, so `query("k")` on its own returns `Base`.
declare function query<T extends Base = Base>(key: string): T;
export const run = (): number => {
const v = query("k") as Derived; // required: without it, `v.extra` is TS2339
return v.extra;
};$ tsc --noEmit # exits 0
$ oxlint --type-aware src # clean
$ oxlint --type-aware --type-check src
src/repro.ts:12:13: error typescript(no-unnecessary-type-assertion):
This assertion is unnecessary since it does not change the type of the expression.--fix rewrites it to const v = query("k");, after which tsc reports TS2339: Property 'extra' does not exist on type 'Base'.
The result is not deterministic
20 modules importing one such declaration (getByTitle<T extends HTMLElement = HTMLElement>(title: string): T, the @testing-library/dom shape), oxlint --type-aware --type-check, false positives per run over 10 identical runs:
| Run configuration | False positives (10 runs) |
|---|---|
| default | 3 3 5 4 9 3 4 4 5 6 |
oxlint --threads=1 |
6 4 4 6 6 6 2 5 6 2 |
GOMAXPROCS=1 |
5 5 5 5 5 5 5 5 5 5 |
oxlint --threads=1 does not help because tsgolint sizes its own worker pool from runtime.GOMAXPROCS(0); pinning GOMAXPROCS=1 makes the output stable, which is what identifies concurrency as the source of the variation.
Because the reported locations move between runs, per-site suppression comments do not work — CI just fails intermittently.
Root cause
checkTypeAssertion compares getUncastType(node) against the asserted type. Instrumenting the rule shows the context-free guard depends on whether the call was checked first:
[ReportSemantic=false] contextFree=any cast=Derived -> 0 diagnostics
[ReportSemantic=true ] contextFree=Derived cast=Derived -> 1 diagnostic (false positive)
RunLinterOnProgram calls reportTypeScriptDiagnostics before the rule pass. With ReportSemantic: true (oxlint's --type-check) that fully checks every file, caching the contextually inferred signature T = Derived on the call node, and Checker_getContextFreeTypeOfExpression then replays the cached signature instead of re-inferring. Without --type-check the same thing happens whenever something else happened to check the call first, which is where the run-to-run variation comes from.
Suggested fix
Detect the shape rather than depend on checker state: an (optionally awaited) call / new / tagged template with no type arguments written, whose resolved signature declares a type parameter that none of its parameters mention. Such a type parameter can only be inferred from the return position or fall back to its default, which is exactly this case.
I have this implemented, with regression tests that run the rule under ReportSemantic: true (the standard rule tester does not reproduce the bug) plus controls for a type argument inferred from an argument and for a plainly redundant assertion. go test ./internal/... and the e2e suite pass, and the 20-module repro above goes to 0 false positives on every run. Happy to open a PR.
Notes
- Found through vite-plus (
vp lint, oxlint 1.77.0 + oxlint-tsgolint 7.0.2001) on my own React +@testing-libraryproject, wherevp checkfailed on 3 of 12 runs; I traced it to tsgolint and reproduced it with plain oxlint as above. Currentmainreproduces too. Note thatvp migrateenablestypeCheckby default when it migrates an.oxlintrc.jsonthat only hadtypeAware, so migrated projects get this without opting in. - typescript-eslint has the same false positive; typescript-eslint#6951 is still open, so fixing it here is a deliberate divergence from upstream.
- Related, not fixed by the above:
Checker_getContextFreeTypeOfExpressioncaches its result on the node, so a laterGetTypeAtLocationon that call returnsany. It is limited to operands of type assertions, but it makes typing order-dependent for other rules on the same node.
AI disclosure: this report and the accompanying fix were produced with AI assistance (Claude Code) and reviewed by me.
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Effort
Activity
mizchi commentedon Aug 19, 2026
Follow-up, since "non-deterministic" invites the question: this is not a data race.
I built
mainwithgo build -raceand ran the 20-module reproduction six times. The false positives still appear (7, 4, 5, 4, 8 per run), and the race detector reports nothing — no output on stderr and no file written withGORACE=log_path.It also reproduces with concurrency fully out of the picture. With
GOMAXPROCS=1the result is deterministic, and it is not zero:So the defect is evaluation-order dependence, not thread safety:
Checker_getContextFreeTypeOfExpressionlooks like a pure "type without the contextual type" query, but the checker caches a call's resolved signature per node, so once that call has been checked it returns the cached — contextual — signature. The rule uses it as if it were state-independent.That the affected files are roughly a quarter of the total fits the checker pool: typescript-go creates 4 checkers and associates files with them round-robin, and node links are per checker, so "has this call been checked" is per checker too — 5 of 20 files is one checker's share. Parallelism only shifts which subset lands in that state; I have not pinned down exactly which path picks a different checker under concurrency, so I am not claiming more than the measurements above.
Caveat: a clean race detector run only shows there was no race on the paths those runs took.
This does not change the fix in #1142 — reading the shape off the signature declaration is state-independent either way. It does mean the bug is fixable in the rule rather than in typescript-go.
perf: add experimental checker scheduling modes (#1240)