biomejs / biome

A toolchain for web projects, aimed to provide functionalities to maintain them. Biome offers formatter and linter, usable via CLI and LSP.
https://biomejs.dev
Apache License 2.0
15.08k stars 471 forks source link

šŸ’… `noUnusedImports` false negative with TypeScript ambient types #2796

Closed samhh closed 5 months ago

samhh commented 5 months ago

Environment information

CLI:
  Version:                      1.7.3
  Color support:                true

Platform:
  CPU Architecture:             aarch64
  OS:                           macos

Environment:
  BIOME_LOG_DIR:                unset
  NO_COLOR:                     unset
  TERM:                         "screen"
  JS_RUNTIME_VERSION:           "v20.12.2"
  JS_RUNTIME_NAME:              "node"
  NODE_PACKAGE_MANAGER:         "yarn/4.1.1"

Biome Configuration:
  Status:                       Loaded successfully
  Formatter disabled:           false
  Linter disabled:              false
  Organize imports disabled:    false
  VCS disabled:                 false

Linter:
  Recommended:                  true
  All:                          false
  Rules:                        complexity/noUselessRename = "warn"
                                complexity/useArrowFunction = "warn"
                                correctness/noUnusedImports = "warn"
                                correctness/noUnusedVariables = "warn"
                                correctness/useExhaustiveDependencies = {"level":"error","options":{"hooks":[{"name":"useEffect","closureIndex":0,"dependenciesIndex":1,"stableResult":"None"},{"name":"useLayoutEffect","closureIndex":0,"dependenciesIndex":1,"stableResult":"None"},{"name":"useInsertionEffect","closureIndex":0,"dependenciesIndex":1,"stableResult":"None"},{"name":"useCallback","closureIndex":0,"dependenciesIndex":1,"stableResult":"None"},{"name":"useMemo","closureIndex":0,"dependenciesIndex":1,"stableResult":"None"},{"name":"useImperativeHandle","closureIndex":1,"dependenciesIndex":2,"stableResult":"None"},{"name":"useState","closureIndex":null,"dependenciesIndex":null,"stableResult":{"Indices":[1]}},{"name":"useReducer","closureIndex":null,"dependenciesIndex":null,"stableResult":{"Indices":[1]}},{"name":"useTransition","closureIndex":null,"dependenciesIndex":null,"stableResult":{"Indices":[1]}},{"name":"useRef","closureIndex":null,"dependenciesIndex":null,"stableResult":"Identity"},{"name":"useDispatch","closureIndex":null,"dependenciesIndex":null,"stableResult":"Identity"}]}}
                                correctness/useHookAtTopLevel = "error"
                                correctness/useJsxKeyInIterable = "off"
                                style/noUselessElse = "off"
                                style/useConsistentArrayType = {"level":"warn","options":{"syntax":"generic"}}
                                style/useImportType = "off"
                                style/useShorthandArrayType = "off"
                                style/useTemplate = "warn"
                                suspicious/noConsoleLog = "warn"
                                suspicious/noFocusedTests = "warn"
                                suspicious/noRedeclare = "off"
                                suspicious/noShadowRestrictedNames = "off"

Workspace:
  Open Documents:               0

Rule name

noUnusedImports

Playground link

https://biomejs.dev/playground/?code=aQBtAHAAbwByAHQAIAB0AHkAcABlACAAKgAgAGEAcwAgAEEAbQBiAGkAZQBuAHQAIABmAHIAbwBtACAAJwAuAC8AbQBvAGQAdQBsAGUALgBqAHMAJwAKAAoAdAB5AHAAZQAgAEIAYQByACAAPQAgAEEAbQBiAGkAZQBuAHQA

Expected result

The provided snippet cannot possibly typecheck unless Ambient is an ambient type, because the Ambient we import is a namespace. Therefore the lint rule should flag the namespace import as unused. The same applies to import *, where it's semi-correctly flagged as only being used in a type context by useImportType. Arguably it should be fixed in the context of each as it seems like a parser issue.

In practice this is an issue in codebases making use of ambient types and namespace imports, such as some fp-ts/similar codebases. For example:

An ambient somewhere to avoid repetitive non-namespaced type imports:

type Option<A> = import('fp-ts/Option').Option<A>

And a module somewhere:

// This'll initially be flagged by useImportType...
import * as Option from 'fp-ts/Option'
// ...so it becomes this...
import type * as Option from 'fp-ts/Option'

// ...however the import is actually still unused, and noUnusedImports doesn't catch it.
export type Foo = Option<string>

Code of Conduct

Conaclos commented 5 months ago

I don't understand what you mean.

If I understand correctly, the code is invalid because you use the namespace as a type. This invalid code could be reported by a rule. Why this code should be reported by noUnusedImports?

Sec-ant commented 5 months ago

This invalid code could be reported by a rule. Why this code should be reported by noUnusedImports?

I believe what OP wants to say is that this doesn't necessarily make the code invalid because we can define an ambient type with the same name in some other .d.ts file, which will make the name valid in a type position. And therefore the unused imported namespace should be reported by noUnusedImports.

Conaclos commented 5 months ago

I believe what OP wants to say is that this doesn't necessarily make the code invalid because we can define an ambient type with the same name in some other .d.ts file, which will make the name valid in a type position. And the imported unused namespace should be reported by noUnusedImports.

Thanks for the clarification. It makes sense. This is related to how the semantic model binds references to declarations.

However, taking a look at the TypeScript playground, it seems that TypeScript binds Option<string> to the Option namespace.

I am unsure what we should do in this case because our semantic model matches the TypeScript semantic model in this particular case.

Sec-ant commented 5 months ago

Some interesting findings:

In VSCode with the TypeScript language server on, if the ambient type is properly defined and discovered, the language server will resolve the type correctly (to the ambient one) and will mark the imported namespace as unused.

image

But if there isn't an ambient type or it is not discovered, it will fail to resolve the type and issue an error. Meanwhile the imported namespace is still marked as unused.

image

[!NOTE] In order for that ambient type to be discovered by the language server, one can either 1) open that ambient type definition file in the editor, 2) use the tripple slash directive /// <reference types="..." />.

However, the TypeScript AST viewer doesn't support a multi-file set-up, so we cannot see for sure whether introducing an ambient type will change the semantic model.

Anyway, it seems to me that project-wise type resolution is involved in the analysis so this might not be a semantic model only issue.


I also came up with another simpler example without the need of ambient types to demonstrate the issue:

import * as A from "./module";

type A = string;

export const a: A = "";

And interestingly, the TypeScript language server will not mark the namespace import as unused and instead it will mark it as the type alias defined below. This seems a bug of TypeScript to me:

image

Biome, however, can correctly mark the namespace import as unused with the above code: https://biomejs.dev/playground/?lintRules=all&code=aQBtAHAAbwByAHQAIAAqACAAYQBzACAAQQAgAGYAcgBvAG0AIAAiAC4ALwBtAG8AZAB1AGwAZQAiADsACgAKAHQAeQBwAGUAIABBACAAPQAgAHMAdAByAGkAbgBnADsACgAKAGUAeABwAG8AcgB0ACAAYwBvAG4AcwB0ACAAYQA6ACAAQQAgAD0AIAAiACIAOwA%3D, but it will trigger a false positive of lint/suspicious/noRedeclare.

Conaclos commented 5 months ago

@Sec-ant Thanks for the investigation ā¤ļø

I think we could improve the semantic model in order to not bind a reference in an ambient context to an import namespace. This means that in the following code, Ns will not reference the import namespace Ns, i twill be reported as an unresolved reference.

import * as Ns from ""
type X = Ns; // unresolved ref

Note that we should still allow namespace access like:

import * as Ns from ""
type X = Ns.Type; // valid, here `Ns` is bound to the import namesapce `Ns`