import/extensions

Enforce that import paths always include the file extension, or never do

Also called import-x/extensions

import "./user" and import "./user.js" can name the same file. Browsers and Node.js only accept the second in ES modules, while bundlers and Bun accept both. This rule keeps a project on one style, for all files or per extension.

It checks import, export ... from, import() and require(). Built-in modules and bare package names such as "react" or "@babel/core" are never checked.

Configure#

Off by default. To turn it on:

bunfig.toml
[lint.rules]
"import/extensions" = "error" # or "warn"

Every option, set to its default:

bunfig.toml
[lint.rules]
"import/extensions" = [
  "error",
  "never", # "always", "ignorePackages", "never" or object
  {
    pattern = {},
    ignorePackages = false,
    checkTypeImports = false,
    pathGroupOverrides = [], # list of objects
  },
]

Examples#

Reported:

user.js
export function createUser(name) {
  return { name };
}
example.jswith "always"1 problem
import { createUser } from "./user";

Not reported:

user.js
export function createUser(name) {
  return { name };
}
example.jswith "always"No problems
import { createUser } from "./user.js";
import { join } from "node:path";
import { useState } from "react";

Options#

bunfig.toml
[lint.rules]
"import/extensions" = ["error", "never", { json = "always" }]

The first option sets the style for every extension:

OptionWhat it does
"never" (default)Report an extension that the path doesn't need, such as ./user.js where ./user finds the same file.
"always"Report a path without an extension.
"ignorePackages"Like "always", but paths into packages such as "lodash/fp/map" aren't checked.
user.js
export function createUser(name) {
  return { name };
}
example.jswith "never"1 problem
import { createUser } from "./user.js";
example.jswith "always"1 problem
import map from "lodash/fp/map";
example.jswith "ignorePackages"No problems
import map from "lodash/fp/map";

An object after it, or instead of it, holds exceptions and more settings:

OptionDefaultWhat it does
an extension, such as js or jsonthe first option"always" or "never" for files with that extension.
pattern{}The same extension entries, in their own object. If you set ignorePackages or checkTypeImports, put the entries in here. Entries written next to those two options are ignored.
ignorePackagesfalseDon't require an extension in paths into packages.
checkTypeImportsfalseAlso require extensions in import type and export type.
pathGroupOverrides[]A list of { pattern, action }. A path that matches the glob pattern is skipped if action is "ignore". With "enforce" it's checked even if it looks like a package. It's ignored unless you also set pattern, ignorePackages or checkTypeImports.
example.jswith "never", { "json": "always" }No problems
import { createUser } from "./user";
import settings from "./settings.json";
example.tswith "always"No problems
import type { User } from "./user";
example.tswith "always", { "checkTypeImports": true }1 problem
import type { User } from "./user";
example.jswith "ignorePackages"1 problem
import { routes } from "~/generated/routes";
example.jswith "always", { "ignorePackages": true, "pathGroupOverrides": [{ "pattern": "~/generated/**", "action": "ignore" }] }No problems
import { routes } from "~/generated/routes";

The rule looks up which file ./user stands for, the way Bun and TypeScript do, so it knows that it's ./user.ts:

user.ts
export function createUser(name: string) {
  return { name };
}
example.tswith "ignorePackages", { "ts": "never" }1 problem
import { createUser } from "./user.ts";
user.ts
export function createUser(name: string) {
  return { name };
}
example.tswith "ignorePackages", { "ts": "never" }No problems
import { createUser } from "./user";

With an ESLint config it only tries .mjs, .js, .json and .node, like Node.js does, unless settings["import/resolver"] says otherwise.