eslint-plugin-jsdoc
Version:
JSDoc linting rules for ESLint.
713 lines (626 loc) • 25.3 kB
JavaScript
/* eslint-disable no-bitwise -- Convenient */
/* eslint-disable jsdoc/reject-any-type -- TypeScript types need not be installed */
import iterateJsdoc from '../iterateJsdoc.js';
import {
createRequire,
} from 'module';
let warned = false;
/** @type {any} */
let ts;
let tsAttempted = false;
// 1. Create a require function bound to the current file's URL
const require = createRequire(import.meta.url);
// Helper to check for standard literals and boolean/enum/template literals
/**
* @param {any} type The type is `ts.Type`
* @returns {boolean}
*/
const isLiteralType = (type) => {
// Standard primitive literals (string, number, bigint, boolean literal flags)
if (type.isLiteral()) {
return true;
}
// Check using TypeFlags for specific edge cases like boolean or template literals
if (type.flags & (ts.TypeFlags.BooleanLiteral | ts.TypeFlags.TemplateLiteral)) {
return true;
}
// If dealing with a union (e.g. 'a' | 'b'), you can check if all constituents are literals
if (type.isUnion()) {
return type.types.every(
/**
* @param {any} typ
*/
(typ) => {
return isLiteralType(typ);
},
);
}
return false;
};
export default iterateJsdoc(({
context,
jsdoc,
jsdocNode,
node: nde,
report,
sourceCode,
utils,
// eslint-disable-next-line complexity -- Numerous type/option permutations
}) => {
// Already handled
if (!ts) {
/* c8 ignore next 3 -- Guard (only reachable if `typescript` failed to load on a prior call) */
if (tsAttempted) {
return;
}
try {
// 2. Attempt to import the package synchronously
ts = require('typescript');
/* c8 ignore next 10 -- Guard */
} catch (error) {
// 3. Fall back gracefully if it is not installed
if (/** @type {{code?: string}} */ (error).code !== 'MODULE_NOT_FOUND') {
// Re-throw if it's a different error (e.g., syntax error inside the package)
throw error;
}
// eslint-disable-next-line no-console -- Warning user
console.warn('⚠️ typescript is not installed. `jsdoc/no-unnecessary-type-assertion` will not work. To disable this warning, you must disable the rule.');
} finally {
tsAttempted = true;
}
return;
}
const types = utils.getTags('type');
// 1. Only proceed if there is a @type tag
if (!types.length) {
return;
}
const {
// https://typescript-eslint.io/rules/no-unnecessary-type-assertion/
checkLiteralConstAssertions = false,
enableFixer = true,
fixTypeChangingCasts = true,
preferConstToLiteralTuples = false,
treatAnyAsRedundant = false,
typesToIgnore = [],
} = context.options[0] ?? {};
/**
* Removes the redundant `@type` tag, deleting the whole JSDoc block if it
* is left empty.
* @returns {void}
*/
const removeType = () => {
utils.removeTag(jsdoc.tags.indexOf(/** @type {any} */ (types[0])), {
removeEmptyBlock: true,
});
// `removeTag` only drops the enclosing block for a single-line comment; for
// a multi-line block whose sole content was the `@type` tag, clear what is
// left (only the delimiter lines) so the now-empty comment is removed too.
const blockIsEmpty = jsdoc.source.every(({
tokens: {
description,
name,
tag,
type,
},
}) => {
return !tag && !type && !name && !description.trim();
});
if (blockIsEmpty) {
jsdoc.source.splice(0);
}
};
const fixer = enableFixer ? removeType : null;
const node =
/**
* @type {import('@typescript-eslint/utils').TSESTree.Node}
*/ (nde);
// 2. Safely get TypeScript parser services and TypeChecker
const services =
/**
* @type {import('@typescript-eslint/utils').ParserServices}
*/ (context.sourceCode.parserServices);
/* c8 ignore next 10 -- Guard */
if (!services || !services.program) {
if (!warned) {
// Cannot proceed without type-aware linting enabled
// eslint-disable-next-line no-console -- Feedback
console.warn('⚠️ You must point ESLint to the `typescript-eslint` parser using `languageOptions`. See the documentation for `jsdoc/no-unnecessary-type-assertion`.');
warned = true;
}
return;
}
// 1. Grab the parser services and the active TypeChecker
// const services = ESLintUtils.getParserServices(context);
const checker = services.program.getTypeChecker();
const assertedTypeStr = types[0].type;
const message = assertedTypeStr === 'const' ?
'The @type tag declaring "{{ type }}" is redundant as TypeScript infers it automatically for literals.' :
'The @type tag declaring "{{ type }}" is redundant as TypeScript infers it automatically.';
const preferConstMessage =
'The @type tag declaring "{{ type }}" is better written as the "const" assertion `/** @type {const} */` (TypeScript 4.5+).';
/**
* Rewrites the reported `@type` tag's type to `const`.
* @returns {void}
*/
const convertToConst = () => {
for (const {
tokens,
} of /** @type {import('comment-parser').Spec} */ (types[0]).source) {
if (tokens.type) {
tokens.type = '{const}';
break;
}
}
};
/**
* Whether `inferredType` is a generic reference carrying `any` type arguments
* that `assertedType` replaces with concrete ones (e.g. an untyped
* `document.querySelectorAll(sel)` giving `NodeListOf<any>`, asserted as
* `NodeListOf<HTMLElement>`, or `new Map()` giving `Map<any, any>`). Such an
* assertion supplies real type information, so it is not redundant even though
* `any` leaves the two types mutually assignable.
* @param {any} inferredType `ts.Type`
* @param {any} assertedType `ts.Type`
* @returns {boolean}
*/
const tightensAnyTypeArgument = (inferredType, assertedType) => {
// Only ever called for an object type, which always carries `objectFlags`.
if ((inferredType.objectFlags & ts.ObjectFlags.Reference) === 0) {
return false;
}
const assertedTypeArguments = checker.getTypeArguments(assertedType);
return checker.getTypeArguments(inferredType).some((inferredTypeArgument, index) => {
return (inferredTypeArgument.flags & ts.TypeFlags.Any) !== 0 &&
assertedTypeArguments[index] !== undefined &&
(assertedTypeArguments[index].flags & ts.TypeFlags.Any) === 0;
});
};
/**
* A generic call/`new` expression takes its type arguments partly from the
* surrounding contextual type, which under a `@type` (a cast, or a
* declaration) is the asserted type itself. `getTypeAtLocation` then just
* echoes the asserted type back, so a genuine tightening looks redundant
* (`document.querySelectorAll(sel)` is really `NodeListOf<Element>`, not the
* asserted `NodeListOf<HTMLElement>`). The uncontaminated type cannot be
* recovered here, so such expressions are left alone.
* @param {any} tsExpression `ts.Node`
* @returns {boolean}
*/
const isGenericCall = (tsExpression) => {
if (!ts.isCallExpression(tsExpression) && !ts.isNewExpression(tsExpression)) {
return false;
}
const signature = /** @type {any} */ (
checker.getResolvedSignature(tsExpression)
);
return Boolean(
signature &&
(signature.typeParameters ?? signature.target?.typeParameters)?.length,
);
};
/**
* A template literal with interpolations widens to `string` on its own, but a
* surrounding `@type` (a cast, or a declaration) contextually narrows it to a
* template-literal type, so `getTypeAtLocation` echoes the asserted literal
* back and a genuine narrowing (`\`${x}Reference\`` really being `string`,
* asserted as `AvailableType`) looks redundant.
* @param {any} estreeExpression
* @returns {boolean}
*/
const isInterpolatedTemplateLiteral = (estreeExpression) => {
return estreeExpression?.type === 'TemplateLiteral' &&
estreeExpression.expressions.length > 0;
};
/**
* Whether `type` carries no real structure to compare against — `any`,
* `never`, `null`, `undefined`, an empty array (`[]` -> `never[]`), an empty
* object (`{}`), or an array whose element type is itself such a placeholder
* (`any[]`). An assertion onto a placeholder is always supplying real type
* information, so it is not redundant.
* @param {any} type `ts.Type`
* @returns {boolean}
*/
const isPlaceholderType = (type) => {
if ((type.flags & (
ts.TypeFlags.Any | ts.TypeFlags.Never |
ts.TypeFlags.Null | ts.TypeFlags.Undefined
)) !== 0) {
return true;
}
if ((type.flags & ts.TypeFlags.Object) === 0) {
return false;
}
if (checker.isArrayType(type)) {
const [
elementType,
] = checker.getTypeArguments(/** @type {import('typescript').TypeReference} */ (
type
));
return !elementType || (elementType.flags & (
ts.TypeFlags.Never | ts.TypeFlags.Undefined | ts.TypeFlags.Any
)) !== 0;
}
const {
objectFlags,
} = /** @type {import('typescript').ObjectType} */ (type);
return (objectFlags & ts.ObjectFlags.EmptyObjectLiteral) !== 0 ||
checker.getPropertiesOfType(type).length === 0;
};
/**
* Whether the JSDoc-asserted type adds nothing over the type TypeScript
* already infers for the expression it is attached to.
* @param {any} rawInferredType `ts.Type`
* @param {any} rawAssertedType `ts.Type`
* @returns {boolean}
*/
const isRedundantAssertion = (rawInferredType, rawAssertedType) => {
if (assertedTypeStr === 'const') {
return checkLiteralConstAssertions && isLiteralType(rawInferredType);
}
if (!treatAnyAsRedundant && assertedTypeStr === 'any') {
return false;
}
if (typesToIgnore.includes(assertedTypeStr)) {
return false;
}
// An `any` expression (e.g. a property of `JSON.parse(s)`) is assignable to
// everything, so a concrete assertion on it is genuinely narrowing rather
// than redundant. (`@type {any}` on `any` still falls through to the
// `treatAnyAsRedundant` handling above/below.)
if (
(rawInferredType.flags & ts.TypeFlags.Any) !== 0 &&
(rawAssertedType.flags & ts.TypeFlags.Any) === 0
) {
return false;
}
// A union such as `never[] | {}` (from `cond ? [] : {}`) reaches the
// primitive path below; if every constituent is a structureless placeholder
// the assertion is supplying the real shape, so it is not redundant.
if (rawInferredType.isUnion() && rawInferredType.types.every(isPlaceholderType)) {
return false;
}
const isObjectOrArray = (rawInferredType.flags & ts.TypeFlags.Object) !== 0;
if (!isObjectOrArray) {
// Primitives and individual literal values ("text" -> string) use unidirectional verification
return checker.isTypeAssignableTo(rawInferredType, rawAssertedType);
}
if (isPlaceholderType(rawInferredType)) {
return false;
}
if (checker.isArrayType(rawInferredType)) {
// Arrays: standard structural bidirectional assignment matches string[] vs string[]
return checker.isTypeAssignableTo(rawInferredType, rawAssertedType) &&
checker.isTypeAssignableTo(rawAssertedType, rawInferredType);
}
if (tightensAnyTypeArgument(rawInferredType, rawAssertedType)) {
return false;
}
// Objects: strip the literal-initialization flags, then require structural
// equivalence in both directions, so `{prop: string}` vs `{prop: string}` is
// redundant while `{prop?: string}` vs `{prop: string}` fails backward.
const inferredBaseType = checker.getBaseTypeOfLiteralType(rawInferredType);
const assertedBaseType = checker.getBaseTypeOfLiteralType(rawAssertedType);
return checker.isTypeAssignableTo(inferredBaseType, assertedBaseType) &&
checker.isTypeAssignableTo(assertedBaseType, inferredBaseType);
};
/**
* Whether the fixer may remove a redundant assertion. With
* `fixTypeChangingCasts` (the default) every redundant assertion may be
* removed. Without it, a broader assertion (e.g. `unknown` on a `string`, or
* `'x' | 'y'` on `'x'`) is only reported, since dropping it would change the
* declared or returned type; it is removed only when the asserted type is
* also assignable back to the inferred one. A plain widening of a literal
* (`string` on `'hello'`) is the type TypeScript gives a mutable binding
* anyway, so it is kept removable.
* @param {any} rawInferredType `ts.Type`
* @param {any} rawAssertedType `ts.Type`
* @returns {boolean}
*/
const mayRemove = (rawInferredType, rawAssertedType) => {
if (
fixTypeChangingCasts || assertedTypeStr === 'const' ||
checker.isTypeAssignableTo(rawAssertedType, rawInferredType)
) {
return true;
}
const inferredBaseType = checker.getBaseTypeOfLiteralType(rawInferredType);
return checker.isTypeAssignableTo(inferredBaseType, rawAssertedType) &&
checker.isTypeAssignableTo(rawAssertedType, inferredBaseType);
};
/**
* A non-`const` tuple type annotation on an array literal (asserting `['foo']`
* onto `['foo']`) turns the literal's inferred `T[]` into a tuple; since the
* literal only takes that tuple type *from* the assertion, the assertion is
* never genuinely redundant even though the contextually-typed expression
* echoes the asserted tuple straight back. It is the pre-TypeScript-4.5
* stand-in for a `const` assertion.
* @param {any} assertedType `ts.Type`
* @param {import('@typescript-eslint/utils').TSESTree.Node | null | undefined} operand
* @returns {boolean}
*/
const isTupleConversionIdiom = (assertedType, operand) => {
return assertedTypeStr !== 'const' &&
operand?.type === 'ArrayExpression' &&
checker.isTupleType(assertedType);
};
/**
* An array-literal annotation whose element type is a literal (or union of
* literals) — `type {ViewType[]}` on `['single-panel', 'user']` —
* narrows the element type that the bare literal would widen (`string[]` ->
* `ViewType[]`). Like the tuple idiom, the literal takes that element type
* only *from* the annotation, so the contextually-typed initializer echoes
* the asserted array straight back and the genuine narrowing looks redundant.
* @param {any} assertedType `ts.Type`
* @param {import('@typescript-eslint/utils').TSESTree.Node | null | undefined} operand
* @returns {boolean}
*/
const narrowsArrayLiteralElement = (assertedType, operand) => {
if (operand?.type !== 'ArrayExpression' || !checker.isArrayType(assertedType)) {
return false;
}
const [
elementType,
] = checker.getTypeArguments(assertedType);
return isLiteralType(elementType);
};
/**
* Whether `operand` is an object literal carrying a method or function-valued
* property. Under a surrounding `@type` object annotation such a function is
* contextually typed by the annotation — its otherwise-implicit-`any`
* parameters (and hence its signature) come straight from it — so
* `getTypeAtLocation` echoes the asserted object shape back and a genuine
* typing looks redundant. As elsewhere in this rule the uncontaminated type
* cannot be recovered here, so such literals are left alone (at the cost of
* missing the rare object annotation that only restates a fully
* self-evident method signature).
* @param {import('@typescript-eslint/utils').TSESTree.Node | null | undefined} operand
* @returns {boolean}
*/
const hasContextuallyTypedFunctionMember = (operand) => {
return operand?.type === 'ObjectExpression' &&
operand.properties.some((property) => {
return property.type === 'Property' && (
property.value.type === 'FunctionExpression' ||
property.value.type === 'ArrowFunctionExpression'
);
});
};
/**
* Whether every element of `tupleType` is a literal type, so a `const`
* assertion would reproduce the same element types (`['foo', 1]` but not
* `[string]`).
* @param {any} tupleType `ts.Type`
* @returns {boolean}
*/
const tupleElementsAllLiteral = (tupleType) => {
const elements = checker.getTypeArguments(tupleType);
return elements.length > 0 && elements.every(isLiteralType);
};
/**
* Reports an array-literal tuple assertion, offering (under `enableFixer`) a
* rewrite to the equivalent, more concise `const` assertion.
* @returns {void}
*/
const reportPreferConst = () => {
utils.reportJSDoc(
preferConstMessage,
types[0],
enableFixer ? convertToConst : null,
false,
{
type: assertedTypeStr,
},
);
};
// Positions where a bare expression of any precedence is valid and equivalent
// to the parenthesized form, so a redundant `/** @type {T} */ (expr)` cast can
// be unwrapped to `expr` without changing meaning.
const unwrappableParentTypes = new Set([
'ArrayExpression',
'AssignmentExpression',
'ReturnStatement',
'ThrowStatement',
'VariableDeclarator',
]);
// 3. `/** @type {T} */ const x = 5;`
if (node?.type === 'VariableDeclaration') {
// A leading `@type` tag only influences the first declarator (TypeScript
// leaves the rest to their own inferred types), so checking `[0]` fully
// determines whether the tag is redundant even for `const a = 1, b = 2;`.
const decl = node.declarations[0];
/* c8 ignore next 4 -- How to cover? */
if (!decl.init) {
// No initializer, type is likely `any`, so @type isn't redundant
return;
}
// Resolve the `@type` tag through the real TypeNode that TypeScript already
// parsed and bound as part of the program. Re-parsing the type string into
// a detached virtual source file (as an earlier approach did) leaves object
// type literals unbound, so `{prop: string}` collapses to `{}`.
const jsdocTypeNode = ts.getJSDocType(
services.esTreeNodeToTSNodeMap.get(decl),
);
/* c8 ignore next 3 -- Every `@type` tag reaching here has a bound TypeNode */
if (!jsdocTypeNode) {
return;
}
const declInitTsNode = services.esTreeNodeToTSNodeMap.get(decl.init);
if (isGenericCall(declInitTsNode) || isInterpolatedTemplateLiteral(decl.init)) {
return;
}
const declInferredType = checker.getTypeAtLocation(declInitTsNode);
const declAssertedType = checker.getTypeFromTypeNode(jsdocTypeNode);
if (isTupleConversionIdiom(declAssertedType, decl.init)) {
if (preferConstToLiteralTuples && tupleElementsAllLiteral(declAssertedType)) {
reportPreferConst();
}
return;
}
if (
narrowsArrayLiteralElement(declAssertedType, decl.init) ||
hasContextuallyTypedFunctionMember(decl.init)
) {
return;
}
if (isRedundantAssertion(declInferredType, declAssertedType)) {
utils.reportJSDoc(
message,
types[0],
mayRemove(declInferredType, declAssertedType) ? fixer : null,
true,
{
type: assertedTypeStr,
},
);
}
return;
}
// 4. Inline cast: `/** @type {T} */ (expr)` (TypeScript's JSDoc assertion).
const exprTsNode = services.esTreeNodeToTSNodeMap.get(node);
const paren = exprTsNode?.parent;
if (!paren || !ts.isParenthesizedExpression(paren)) {
return;
}
const typeTag = ts.getJSDocTypeTag(paren);
/* c8 ignore next 4 -- Defensive: `getJSDocTypeTag` can also surface a `@type`
inherited from an enclosing statement, whose position precedes the paren */
if (!typeTag || typeTag.pos < paren.pos) {
return;
}
// The `@type` TypeScript resolves for `paren` must be the very comment being
// iterated. When the comment actually sits on an inner parenthesized
// sub-expression that is then a member/argument of the paren's operand
// (`/** @type {DOMException} */ (reader.error).message`), `paren` is a
// different, outer cast carrying a different comment, so measuring or fixing
// against it would target the wrong expression (and drop the inner cast).
if (typeTag.parent.end !== jsdocNode.range[1]) {
return;
}
if (isGenericCall(exprTsNode) || isInterpolatedTemplateLiteral(node)) {
return;
}
// The `unknown` half of a "cast through `unknown`"
// (`/** @type {T} */ (/** @type {unknown} */ (x))`) is the load-bearing
// bridge that lets the outer assertion reach an otherwise-incompatible type;
// it is never redundant, even though `unknown` is broader than everything.
if (assertedTypeStr === 'unknown' && ts.isParenthesizedExpression(paren.parent)) {
const outerTypeTag = ts.getJSDocTypeTag(paren.parent);
if (outerTypeTag && outerTypeTag.pos >= paren.parent.pos) {
return;
}
}
const parent = /** @type {any} */ (node.parent);
const declaration = parent.type === 'VariableDeclarator' ? parent.parent : null;
// A `/** @type {const} */` cast only fails to do real work on a `const`
// declarator, which already infers the literal type. Anywhere else (`let`, a
// return, an object-property value, some generic call arguments, …) it
// suppresses widening, so it is not redundant.
if (assertedTypeStr === 'const' && declaration?.kind !== 'const') {
return;
}
// For a mutable binding (`let`/`var`) TypeScript widens the initializer, so a
// narrowing cast such as `/** @type {5} */` is doing real work; widen the
// uncast type to match before judging redundancy.
const mutableBinding = Boolean(declaration) && declaration.kind !== 'const';
const castInferredType = mutableBinding ?
checker.getBaseTypeOfLiteralType(checker.getTypeAtLocation(exprTsNode)) :
checker.getTypeAtLocation(exprTsNode);
const castAssertedType = checker.getTypeFromTypeNode(typeTag.typeExpression.type);
if (isTupleConversionIdiom(castAssertedType, node)) {
if (preferConstToLiteralTuples && tupleElementsAllLiteral(castAssertedType)) {
reportPreferConst();
}
return;
}
if (
narrowsArrayLiteralElement(castAssertedType, node) ||
hasContextuallyTypedFunctionMember(node)
) {
return;
}
if (!isRedundantAssertion(castInferredType, castAssertedType)) {
return;
}
const canUnwrap = enableFixer && mayRemove(castInferredType, castAssertedType) && (
unwrappableParentTypes.has(parent.type) ||
(parent.type === 'ConditionalExpression' && parent.test !== node) ||
((parent.type === 'CallExpression' || parent.type === 'NewExpression') &&
parent.callee !== node)
);
report(
message,
canUnwrap ?
/**
* @param {import('eslint').Rule.RuleFixer} ruleFixer
* @returns {import('eslint').Rule.Fix}
*/
(ruleFixer) => {
const closeParen = /** @type {import('eslint').AST.Token} */ (
sourceCode.getTokenAfter(/** @type {any} */ (node), {
filter: ({
type,
value,
}) => {
return type === 'Punctuator' && value === ')';
},
})
);
return ruleFixer.replaceTextRange(
[
jsdocNode.range[0], closeParen.range[1],
],
sourceCode.getText(/** @type {any} */ (node)),
);
} :
null,
types[0],
{
type: assertedTypeStr,
},
);
}, {
iterateAllJsdocs: true,
meta: {
docs: {
description: 'Reports redundant @type tags that match or broaden the naturally inferred TypeScript type.',
},
fixable: 'code',
schema: [
{
additionalProperties: false,
properties: {
checkLiteralConstAssertions: {
description: 'Whether to check `const` type assertions as redundant',
type: 'boolean',
},
enableFixer: {
description: 'Whether to enable the fixer that removes the redundant `@type` tag (and the JSDoc block if it becomes empty). Defaults to `true`.',
type: 'boolean',
},
fixTypeChangingCasts: {
description: 'Whether the fixer may also remove a redundant assertion that is broader than the inferred type, which changes the declared, exported or returned type (e.g. `/** @type {unknown} */` on a `string`). When `false`, such an assertion is still reported but not fixed. Only matters when `enableFixer` is `true`. Defaults to `true`.',
type: 'boolean',
},
preferConstToLiteralTuples: {
description: 'Whether to report a non-`const` literal-tuple assertion on an array literal (e.g. `/** @type {[\'foo\']} */ ([\'foo\'])`) and fix it to the equivalent, more concise `/** @type {const} */` assertion. Defaults to `false`.',
type: 'boolean',
},
treatAnyAsRedundant: {
description: 'Whether to treat `any` type casts as redundant',
type: 'boolean',
},
typesToIgnore: {
description: 'An array list of types to ignore',
items: {
type: 'string',
},
type: 'array',
},
},
type: 'object',
},
],
type: 'suggestion',
},
});