Skip to content

Bug: [no-unnecessary-type-assertion] non-deterministic false positive whose autofix breaks the build #1141

Description

@mizchi

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-library project, where vp check failed on 3 of 12 runs; I traced it to tsgolint and reproduced it with plain oxlint as above. Current main reproduces too. Note that vp migrate enables typeCheck by default when it migrates an .oxlintrc.json that only had typeAware, 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_getContextFreeTypeOfExpression caches its result on the node, so a later GetTypeAtLocation on that call returns any. 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.

Activity

  1. mizchi commented on Aug 19, 2026

    @mizchi
    Author

    Follow-up, since "non-deterministic" invites the question: this is not a data race.

    I built main with go build -race and 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 with GORACE=log_path.

    It also reproduces with concurrency fully out of the picture. With GOMAXPROCS=1 the result is deterministic, and it is not zero:

    GOMAXPROCS=1   mod1 mod6 mod13 mod17 mod20     (identical on every run)
    default        mod7 mod9 mod13 mod17
                   mod2 mod4 mod11 mod13 mod17 mod18
                   mod4 mod7 mod8 mod13
    

    So the defect is evaluation-order dependence, not thread safety: Checker_getContextFreeTypeOfExpression looks 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.

  2. added theissue type on Aug 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Fields

    Priority

    None yet

    Effort

    None yet

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions