@mkvisuals/eslint-plugin-throws
v0.6.1
Published
ESLint plugin that brings checked exception awareness to JavaScript/TypeScript via @throws JSDoc tags
Maintainers
Readme
@mkvisuals/eslint-plugin-throws
Bring Java/PHP-style checked exception awareness to JavaScript and TypeScript via @throws JSDoc tags.
When a function is annotated with @throws, every call site must either be wrapped in a try/catch block or the calling function must itself declare @throws (propagation). The plugin also optionally enforces that any function containing a throw statement has a @throws annotation.
Works with ESLint 8 (legacy eslintrc) and ESLint 9 (flat config). No external dependencies.
Installation
npm install --save-dev @mkvisuals/eslint-plugin-throwsUsage
ESLint 9 — flat config
// eslint.config.js
import throws from '@mkvisuals/eslint-plugin-throws';
export default [
// Use the recommended preset (warn on uncaught @throws call sites)
...throws.configs.recommended,
];Or with the strict preset (also requires @throws on any function that throws):
export default [
...throws.configs.strict,
];Or configure manually with all options:
import throws from '@mkvisuals/eslint-plugin-throws';
export default [
{
plugins: { throws },
rules: {
'throws/no-uncaught-throws': ['warn', {
requireThrowsAnnotation: true,
removeUnnecessaryThrows: true,
fixStrategy: 'propagate',
ignoreThrownCallees: ['JSON.stringify'],
}],
},
},
];ESLint 8 — legacy eslintrc
// .eslintrc.cjs
module.exports = {
plugins: ['@mkvisuals/eslint-plugin-throws'],
rules: {
'throws/no-uncaught-throws': 'warn',
// or with all options:
// 'throws/no-uncaught-throws': ['warn', {
// requireThrowsAnnotation: true,
// removeUnnecessaryThrows: true,
// fixStrategy: 'propagate',
// ignoreThrownCallees: ['JSON.stringify'],
// }],
},
};TypeScript — cross-file resolution
By default the plugin resolves callees within the same file only. To enable cross-file resolution (e.g. this.service.method() via NestJS dependency injection), configure @typescript-eslint/parser with type information:
npm install --save-dev typescript-eslint// eslint.config.js
import throws from '@mkvisuals/eslint-plugin-throws';
import tseslint from 'typescript-eslint';
export default [
{
files: ['**/*.{js,ts}'],
plugins: { throws },
rules: {
'throws/no-uncaught-throws': ['warn', { requireThrowsAnnotation: true }],
},
},
{
files: ['**/*.ts'],
languageOptions: {
parser: tseslint.parser,
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
];When type information is available, the plugin uses TypeScript's type checker to resolve method calls across files and read @throws JSDoc tags from the target declaration. Without type information, it falls back to same-file resolution.
Rules
throws/no-uncaught-throws
Warns when a function annotated with @throws is called without a surrounding try/catch and the caller itself has no @throws annotation.
Examples
Incorrect — findItem declares @throws but the call is unguarded:
/** @throws {NotFoundException} */
function findItem(id) { /* ... */ }
function loadUser(id) {
const item = findItem(id); // ⚠ warning
}Correct — wrapped in try/catch:
function loadUser(id) {
try {
const item = findItem(id);
} catch (error) {
// handle it
}
}Catch clause instanceof filtering
When the catch block uses instanceof to filter error types, the plugin checks whether each declared @throws type is actually handled. Types that fall through unhandled produce a warning.
Incorrect — MyError is thrown but the catch only handles a different type:
/** @throws {MyError} */
function risky() { /* ... */ }
function caller() {
try {
risky();
} catch (e) {
if (e instanceof OtherError) {
// handle OtherError
}
// ⚠ MyError falls through unhandled
}
}Correct — the catch handles MyError explicitly:
function caller() {
try {
risky();
} catch (e) {
if (e instanceof MyError) {
// handled
}
}
}Correct — else clause acts as a catch-all:
function caller() {
try {
risky();
} catch (e) {
if (e instanceof OtherError) {
// ...
} else {
// catch-all handles MyError
}
}
}A bare catch (e) { ... } without any instanceof filtering is treated as handling everything (no warning).
Correct — propagation via @throws on the caller:
/** @throws {NotFoundException} */
function loadUser(id) {
const item = findItem(id); // OK — caller declares @throws
}IDE suggestions (lightbulb fixes)
Each warning offers two quick-fix suggestions:
- Propagate @throws to enclosing function — adds/appends
@throws {Type}to the caller's JSDoc - Wrap in try/catch — wraps the statement in
try { ... } catch (error) { throw error; }
Options
'throws/no-uncaught-throws': ['warn', {
requireThrowsAnnotation: false, // default
removeUnnecessaryThrows: false, // default
fixStrategy: undefined, // default (no autofix)
ignoreThrownCallees: [], // default
}]| Option | Type | Default | Description |
|--------|------|---------|-------------|
| requireThrowsAnnotation | boolean | false | When true, warns when a function contains a throw statement but has no @throws JSDoc annotation. |
| removeUnnecessaryThrows | boolean | false | When true, warns when a function has a @throws annotation but does not throw or call any function annotated with @throws. Autofixes by removing the stale annotation. |
| fixStrategy | 'propagate' | 'try-catch' | — | Enables eslint --fix for uncaught throws. 'propagate' adds @throws to the calling function. 'try-catch' wraps the call in a try/catch block. When not set, only IDE suggestions are provided (no autofix). |
| ignoreThrownCallees | string[] | [] | List of callee patterns (e.g. 'JSON.stringify') to skip entirely — both as throw arguments (throw JSON.stringify(x)) and as plain calls (JSON.stringify(x)). Strongly recommended for TypeScript projects: ['JSON.stringify'] — TS lib types annotate it with @throws {TypeError} and every call would otherwise warn. |
requireThrowsAnnotation
With requireThrowsAnnotation: true:
function riskyFn() {
throw new NotFoundException(); // ⚠ missing @throws annotation
}The suggestion Add @throws annotation will create or append the correct @throws {NotFoundException} tag.
removeUnnecessaryThrows
With removeUnnecessaryThrows: true:
/** @throws {NotFoundException} */
function safeFn() {
// no throw, no calls to @throws functions
return 42; // ⚠ unnecessary @throws annotation
}The fix removes the @throws lines from the JSDoc. If the JSDoc only contained @throws, the entire comment is removed.
The force keyword
When calling external libraries that throw but don't have @throws annotations (compiled code, no JSDoc), you can mark a @throws tag with force to prevent it from being flagged as unnecessary:
/**
* @throws {ORMException} force - TypeORM save() internally throws this
*/
async save(entity: User): Promise<void> {
await this.repository.save(entity); // ✅ no "unnecessary" warning
}The force keyword (and noPropagate, see below) must appear immediately after the type, before any free-form description. Everything after the recognized modifiers — or after a - separator — is treated as a normal description. Callers of a force-only function still need to handle or propagate the @throws as usual.
The noPropagate keyword
A @throws tag marked noPropagate declares that the function may throw the type — so requireThrowsAnnotation is satisfied — but callers are exempt from catching or propagating it. Useful for defensive rethrows where the type system says Error but you don't want every caller in the call chain to wear it.
/**
* @throws {ValidationError}
* @throws {NotFoundError}
* @throws {Error} noPropagate - defensive rethrow in else branch
*/
function handle() {
try {
risky();
} catch (e) {
if (e instanceof ValidationError) throw e;
else if (e instanceof NotFoundError) throw e;
else throw e; // typed as Error — declared but not propagated to callers
}
}
function caller() {
handle(); // ✅ no need to catch or declare {Error}
}noPropagate also absorbs throws inside inline callbacks: when a callback declares @throws {Type} noPropagate, throws of that type don't bubble up to the enclosing function's requireThrowsAnnotation check.
/** @throws {OuterError} force */
function doStuff() {
someAsyncRunner(
/** @throws {CallbackException} noPropagate */
async () => {
throw new CallbackException(); // ✅ doesn't force doStuff to declare it
}
);
}noPropagate is composable with force in either order: @throws {Type} force noPropagate and @throws {Type} noPropagate force both work. Note that noPropagate does not auto-keep a tag from removeUnnecessaryThrows — only force does that.
ignoreThrownCallees
🔥 If you use TypeScript with type-aware linting, you almost certainly want
ignoreThrownCallees: ['JSON.stringify']. TypeScript's lib type definitions annotateJSON.stringifywith@throws {TypeError}, so every plainJSON.stringify(...)call in your codebase will be flagged as an unguarded throw. This is the single most common source of false positives — add it to your config and move on with your life.
ignoreThrownCallees accepts a list of callee patterns to skip in two situations:
1. Calls to functions whose @throws you don't care about (default mode):
'throws/no-uncaught-throws': ['warn', {
ignoreThrownCallees: ['JSON.stringify'],
}]function format(data: unknown) {
return JSON.stringify(data); // ✅ no warning, even though TS lib says @throws {TypeError}
}2. throw foo() patterns where the call result isn't a real exception type (requireThrowsAnnotation mode):
function formatAndThrow(data) {
throw JSON.stringify(data); // ✅ ignored — would otherwise demand @throws {JSON}
}Each entry is matched against the callee shape — both Object.method (single-level member expression) and bare funcName patterns are supported. Wrapping in new Error(JSON.stringify(...)) is not matched (and would correctly resolve to Error).
fixStrategy
With fixStrategy: 'propagate', running eslint --fix will automatically add @throws to the enclosing function:
/** @throws {NotFoundException} */
function findItem(id) { /* ... */ }
// Before fix:
function loadUser(id) {
const item = findItem(id); // ⚠ warning
}
// After eslint --fix:
/** @throws {NotFoundException} */
function loadUser(id) {
const item = findItem(id); // ✅ fixed
}With fixStrategy: 'try-catch', running eslint --fix wraps the call instead:
// After eslint --fix:
function loadUser(id) {
try {
const item = findItem(id); // ✅ fixed
} catch (error) {
throw error;
}
}Resolution scope
The rule resolves callees within the same file using ESLint's scope analysis:
- Direct calls:
foo() this.method()inside a classobj.method()for locally-defined object literals
Cross-file resolution (TypeScript)
When @typescript-eslint/parser is configured with type information (parserOptions.projectService or parserOptions.project), the rule also resolves cross-file calls:
this.service.method()— injected dependencies (e.g. NestJS DI)- Any member expression call where TypeScript can resolve the type
The plugin reads @throws JSDoc tags from the resolved method declaration via TypeScript's type checker. No additional dependencies are required beyond typescript-eslint.
Type-level matching
The rule compares exception types between caller and callee. Only uncovered exception types produce warnings:
/** @throws {NotFoundException} */
function findItem(id: string) { /* ... */ }
/** @throws {NotFoundException} */
function loadUser(id: string) {
return findItem(id); // ✅ NotFoundException is covered
}If a callee throws multiple types, only the ones not declared in the caller's @throws are reported:
/** @throws {NotFoundException} */
/** @throws {ForbiddenException} */
function riskyCall() { /* ... */ }
/** @throws {NotFoundException} */
function handler() {
riskyCall(); // ⚠ ForbiddenException is not covered
}Exception inheritance (TypeScript)
When type information is available, the rule understands class inheritance. A parent exception type covers all of its subclasses:
class AppException extends Error {}
class NotFoundException extends AppException {}
class ForbiddenException extends AppException {}
/** @throws {NotFoundException} */
function findItem() { /* ... */ }
/** @throws {AppException} */
function handler() {
findItem(); // ✅ NotFoundException extends AppException — covered
}This works across files — the exception classes don't need to be imported in the calling file. The plugin resolves the inheritance chain via TypeScript's type checker.
Note: Exception inheritance checking requires TypeScript with type-aware linting (see cross-file setup). In plain JavaScript, only exact type name matching is used.
License
MIT
