JSDoc `@type` on a function: the type in a type predicate is never checked (unused `@import` reported, missing names not reported)
[!NOTE] This issue was created by Claude (Anthropic's AI assistant), with direction from @ljharb. All compiler output below is from actual
tscruns.
Search Terms
"declared but never used" jsdoc, TS6196, TS6133, TS2304, @import unused, jsdoc type predicate unused import, jsdoc type predicate "Cannot find name", @type function declaration type predicate, asserts jsdoc, noUnusedLocals checkJs, FullSignature
Version & Regression Information
- This changed between versions 6.0.3 and 7.0.2 (also reproduces on 7.1.0-dev.20260915.1)
⏯ Playground Link
No response (the repro needs a second file for the @import, plus a tsconfig)
Code
// tsconfig.json
{
"compilerOptions": {
"module": "nodenext",
"allowJs": true,
"checkJs": true,
"noEmit": true,
"noUnusedLocals": true
}
}
// types.d.mts
export type Foo = Error & { code: 'X' };
// isFoo.mjs
/** @import { Foo } from './types.d.mts' */
/** @type {(e: unknown) => e is Foo} */
export function isFoo(e) {
return e instanceof Error && 'code' in e && e.code === 'X';
}
Actual behavior
isFoo.mjs(1,15): error TS6196: 'Foo' is declared but never used.
Foo is used, in the e is Foo type predicate of the @type tag.
Expected behavior
No error, as in 6.0.3.
Additional information about the issue
Each form below was tested in its own file with the same @import and tsconfig:
| Form | 7.1.0-dev.20260915.1 | 7.0.2 | 6.0.3 |
|---|---|---|---|
@type {(e: unknown) => e is Foo} on a function declaration (above) |
TS6196 | TS6196 | no error |
@type {(e: unknown) => asserts e is Foo} on a function declaration |
TS6196 | TS6196 | no error |
export default /** @type {(e: unknown) => e is Foo} */ (e) => … |
TS6196 | TS6196 | TS6133 |
@param {unknown} e + @returns {e is Foo} |
no error | no error | TS6133 |
JSDoc cast: /** @type {(e: unknown) => e is Foo} */ ((e) => …) |
no error | no error | no error |
@type {(e: Foo) => boolean} (Foo as a parameter type) |
no error | no error | no error |
| the first form, plus a call in the same file that narrows with it | no error | no error | no error |
Workaround: drop the @import and write e is import('./types.d.mts').Foo.
The predicate's type doesn't seem to be checked at all: names that don't exist aren't reported there either, while the same names in the parameter or return type are (same tsconfig, no imports):
// unchecked.mjs
/** @type {(e: unknown) => e is Missing1} */
export function a(e) {
return !!e;
}
/** @type {(e: unknown) => asserts e is Missing2} */
export function b(e) {
if (!e) {
throw new TypeError();
}
}
/** @type {(e: Missing3) => boolean} */
export function c(e) {
return !!e;
}
/** @type {(e: unknown) => Missing4} */
export function d(e) {
return /** @type {never} */ (e);
}
7.1.0-dev.20260915.1 and 7.0.2:
unchecked.mjs(13,16): error TS2304: Cannot find name 'Missing3'.
unchecked.mjs(18,28): error TS2304: Cannot find name 'Missing4'.
6.0.3:
unchecked.mjs(1,33): error TS2304: Cannot find name 'Missing1'.
unchecked.mjs(6,41): error TS2304: Cannot find name 'Missing2'.
unchecked.mjs(13,16): error TS2304: Cannot find name 'Missing3'.
unchecked.mjs(18,28): error TS2304: Cannot find name 'Missing4'.
Possibly relevant, but I haven't confirmed it: when a @type tag supplies a function's FullSignature, the checker only uses it for the argument-count check (checker.go#L3464-L3468, checker.go#L10320-L10324) and never runs checkSourceElement on it. If so, the predicate's type is never checked or marked as referenced unless something else resolves the predicate, like the same-file call in the last row of the table. #64052 (the open fix for #63754) edits these same lines, but only to strip undefined from the @type's type, so it wouldn't change this.
Source: microsoft/TypeScript