Skip to content

Commit 139adfe

Browse files
committed
feat(linter): add @typescript-eslint/no-import-type-side_effects (#3699)
1 parent a048493 commit 139adfe

File tree

3 files changed

+200
-0
lines changed

3 files changed

+200
-0
lines changed

crates/oxc_linter/src/rules.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ mod typescript {
133133
pub mod no_empty_interface;
134134
pub mod no_explicit_any;
135135
pub mod no_extra_non_null_assertion;
136+
pub mod no_import_type_side_effects;
136137
pub mod no_misused_new;
137138
pub mod no_namespace;
138139
pub mod no_non_null_asserted_optional_chain;
@@ -522,6 +523,7 @@ oxc_macros::declare_all_lint_rules! {
522523
typescript::no_empty_interface,
523524
typescript::no_explicit_any,
524525
typescript::no_extra_non_null_assertion,
526+
typescript::no_import_type_side_effects,
525527
typescript::no_misused_new,
526528
typescript::no_namespace,
527529
typescript::no_non_null_asserted_optional_chain,
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
use oxc_ast::{
2+
ast::{ImportDeclarationSpecifier, ImportOrExportKind},
3+
AstKind,
4+
};
5+
use oxc_diagnostics::OxcDiagnostic;
6+
use oxc_macros::declare_oxc_lint;
7+
use oxc_span::{GetSpan, Span};
8+
9+
use crate::{context::LintContext, rule::Rule, AstNode};
10+
11+
fn no_import_type_side_effects_diagnostic(span0: Span) -> OxcDiagnostic {
12+
OxcDiagnostic::warn("typescript-eslint(no-import-type-side-effects): TypeScript will only remove the inline type specifiers which will leave behind a side effect import at runtime.")
13+
.with_help("Convert this to a top-level type qualifier to properly remove the entire import.")
14+
.with_labels([span0.into()])
15+
}
16+
17+
#[derive(Debug, Default, Clone)]
18+
pub struct NoImportTypeSideEffects;
19+
20+
declare_oxc_lint!(
21+
/// ### What it does
22+
///
23+
/// Enforce the use of top-level import type qualifier when an import only has specifiers with inline type qualifiers.
24+
///
25+
/// ### Why is this bad?
26+
///
27+
/// The `--verbatimModuleSyntax` compiler option causes TypeScript to do simple and predictable transpilation on import declarations.
28+
/// Namely, it completely removes import declarations with a top-level type qualifier, and it removes any import specifiers with an inline type qualifier.
29+
///
30+
/// The latter behavior does have one potentially surprising effect in that in certain cases TS can leave behind a "side effect" import at runtime:
31+
32+
/// ```javascript
33+
/// import { type A, type B } from 'mod';
34+
/// ```
35+
36+
/// is transpiled to
37+
///
38+
/// ```javascript
39+
/// import {} from 'mod';
40+
/// which is the same as
41+
/// import 'mod';
42+
/// ```
43+
44+
/// For the rare case of needing to import for side effects, this may be desirable - but for most cases you will not want to leave behind an unnecessary side effect import.
45+
///
46+
/// ### Example
47+
/// ```javascript
48+
/// import { type A } from 'mod';
49+
/// import { type A as AA } from 'mod';
50+
/// import { type A, type B } from 'mod';
51+
/// import { type A as AA, type B as BB } from 'mod';
52+
/// ```
53+
NoImportTypeSideEffects,
54+
restriction,
55+
);
56+
57+
impl Rule for NoImportTypeSideEffects {
58+
fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) {
59+
let AstKind::ImportDeclaration(import_decl) = node.kind() else {
60+
return;
61+
};
62+
63+
if matches!(import_decl.import_kind, ImportOrExportKind::Type) {
64+
return;
65+
}
66+
67+
let Some(specifiers) = &import_decl.specifiers else {
68+
return;
69+
};
70+
71+
let mut type_specifiers = vec![];
72+
73+
for specifier in specifiers {
74+
let ImportDeclarationSpecifier::ImportSpecifier(specifier) = specifier else {
75+
return;
76+
};
77+
if matches!(specifier.import_kind, ImportOrExportKind::Value) {
78+
return;
79+
}
80+
type_specifiers.push(specifier);
81+
}
82+
// Can report and fix only if all specifiers are inline `type` qualifier:
83+
// `import { type A, type B } from 'foo.js'`
84+
ctx.diagnostic_with_fix(
85+
no_import_type_side_effects_diagnostic(import_decl.span),
86+
|fixer| {
87+
let mut delete_ranges = vec![];
88+
89+
for specifier in type_specifiers {
90+
// import { type A } from 'foo.js'
91+
// ^^^^^^^^
92+
delete_ranges
93+
.push(Span::new(specifier.span.start, specifier.imported.span().start));
94+
}
95+
96+
let mut output = String::new();
97+
let mut last_pos = import_decl.span.start;
98+
for range in delete_ranges {
99+
// import { type A } from 'foo.js'
100+
// ^^^^^^^^^^^^^^^
101+
// | |
102+
// [last_pos range.start)
103+
output.push_str(ctx.source_range(Span::new(last_pos, range.start)));
104+
// import { type A } from 'foo.js'
105+
// ^
106+
// |
107+
// last_pos
108+
last_pos = range.end;
109+
}
110+
111+
// import { type A } from 'foo.js'
112+
// ^^^^^^^^^^^^^^^^^^
113+
// ^ ^
114+
// | |
115+
// [last_pos import_decl_span.end)
116+
output.push_str(ctx.source_range(Span::new(last_pos, import_decl.span.end)));
117+
118+
if let Some(output) = output.strip_prefix("import ") {
119+
let output = format!("import type {output}");
120+
fixer.replace(import_decl.span, output)
121+
} else {
122+
// Do not do anything, this should never happen
123+
fixer.replace(import_decl.span, ctx.source_range(import_decl.span))
124+
}
125+
},
126+
);
127+
}
128+
}
129+
130+
#[test]
131+
fn test() {
132+
use crate::tester::Tester;
133+
134+
let pass = vec![
135+
"import T from 'mod';",
136+
"import * as T from 'mod';",
137+
"import { T } from 'mod';",
138+
"import type { T } from 'mod';",
139+
"import type { T, U } from 'mod';",
140+
"import { type T, U } from 'mod';",
141+
"import { T, type U } from 'mod';",
142+
"import type T from 'mod';",
143+
"import type T, { U } from 'mod';",
144+
"import T, { type U } from 'mod';",
145+
"import type * as T from 'mod';",
146+
"import 'mod';",
147+
];
148+
149+
let fail = vec![
150+
"import { type A } from 'mod';",
151+
"import { type A as AA } from 'mod';",
152+
"import { type A, type B } from 'mod';",
153+
"import { type A as AA, type B as BB } from 'mod';",
154+
];
155+
156+
let fix = vec![
157+
("import { type A } from 'mod';", "import type { A } from 'mod';", None),
158+
("import { type A as AA } from 'mod';", "import type { A as AA } from 'mod';", None),
159+
("import { type A, type B } from 'mod';", "import type { A, B } from 'mod';", None),
160+
(
161+
"import { type A as AA, type B as BB } from 'mod';",
162+
"import type { A as AA, B as BB } from 'mod';",
163+
None,
164+
),
165+
];
166+
Tester::new(NoImportTypeSideEffects::NAME, pass, fail).expect_fix(fix).test_and_snapshot();
167+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
source: crates/oxc_linter/src/tester.rs
3+
expression: no_import_type_side_effects
4+
---
5+
typescript-eslint(no-import-type-side-effects): TypeScript will only remove the inline type specifiers which will leave behind a side effect import at runtime.
6+
╭─[no_import_type_side_effects.tsx:1:1]
7+
1import { type A } from 'mod';
8+
· ─────────────────────────────
9+
╰────
10+
help: Convert this to a top-level type qualifier to properly remove the entire import.
11+
12+
typescript-eslint(no-import-type-side-effects): TypeScript will only remove the inline type specifiers which will leave behind a side effect import at runtime.
13+
╭─[no_import_type_side_effects.tsx:1:1]
14+
1import { type A as AA } from 'mod';
15+
· ───────────────────────────────────
16+
╰────
17+
help: Convert this to a top-level type qualifier to properly remove the entire import.
18+
19+
typescript-eslint(no-import-type-side-effects): TypeScript will only remove the inline type specifiers which will leave behind a side effect import at runtime.
20+
╭─[no_import_type_side_effects.tsx:1:1]
21+
1import { type A, type B } from 'mod';
22+
· ─────────────────────────────────────
23+
╰────
24+
help: Convert this to a top-level type qualifier to properly remove the entire import.
25+
26+
typescript-eslint(no-import-type-side-effects): TypeScript will only remove the inline type specifiers which will leave behind a side effect import at runtime.
27+
╭─[no_import_type_side_effects.tsx:1:1]
28+
1import { type A as AA, type B as BB } from 'mod';
29+
· ─────────────────────────────────────────────────
30+
╰────
31+
help: Convert this to a top-level type qualifier to properly remove the entire import.

0 commit comments

Comments
 (0)