Flat Config で Svelte+TypeScript を ESLint する
ESLintでは設定ファイルを複雑な旧形式 eslintrc から改め、独自ルールの少ない新形式 flat config に移行しています。
先日リリースされた ESLint v9では flat config が旧形式に代わってデフォルトの設定ファイルとなっており、次のメジャーバージョンであるESLint v10以降は旧形式の設定ファイルはサポートされなくなります。(cf. Flat config rollout plans - ESLint)
ESLint v9がリリースされた今、趣味で作っている SvelteKit プロジェクトを flat config 移行することにしました。
忙しい人は §3 移行まとめ を読むと雰囲気がつかめるかもしれません。
Svelte+TypeScript の構成に限らず、flat config 移行でのつまづきをなくすように書いたつもりです。
§0 前提等
Svelte ないし SvelteKit のプロジェクトでは、リンタとして ESLint、フォーマッタとして Prettier を利用する構成が一般的かと思います。
この場合、ESLint プラグインとしては、主として typescript-eslint, eslint-plugin-svelte, eslint-config-prettier を使用されているはずです。
本記事では、これら3プラグインを flat config を介して設定する方法を紹介します。
§0.1 各種ライブラリのバージョン
Flat config への移行を行う前に各種ライブラリのバージョンアップをしておきます。
予めアンインストールして古い依存関係を残さないことで依存するパッケージを最新に保ちメンテナンスを楽にする効果が期待できます(npm の場合、lockfileVersionが3なら特に問題ないかもしれません)。
npm un eslint typescript-eslint eslint-plugin-svelte eslint-config-prettier
npm i -D eslint typescript-eslint eslint-plugin-svelte eslint-config-prettier
なお、本記事執筆時点 (2024-04-29) では typescript-eslint の ESLint v9 への移行対応 が終了していないことなどから、以下のバージョンを使用しています。
| npm パッケージ名 | バージョン | 備考 |
|---|---|---|
| eslint | 8.57.0 |
最新は v9.1.0 ですが、typescript-eslintパッケージが対応していないため^8.0.0を用います |
| typescript-eslint | 7.7.1 |
v7から@typescript-eslint/parserと@typescript-eslint/eslint-pluginが統合されました。ESLint^8.56.0に対応しています |
| eslint-plugin-svelte | 2.38.0 |
Svelte^3.37.0 || ^4.0.0 || ^5.0.0-next.112に対応しています |
| eslint-config-prettier | 9.1.0 |
ESLint>=7.0.0に対応しています |
| @types/node | 20.12.7 |
Node.js>=20.11.0で使えるimport.meta.dirnameの型情報を使います |
また、import.meta.dirnameを使いたいので Node.js^20.11.0を使います。
CommonJS module やこれより古い Node.js をお使いの場合は、代わりに
__dirnameをお使いください。
Linting with Type Information | typescript-eslint より引用・和訳
§1 ESLint 公式のマイグレーションガイドにしたがって Flat Config に移行する
趣味で作っている SvelteKit プロジェクトの.eslintrc.cjsファイルを ESLint 公式のマイグレーションガイドにしたがって flat config に移行してみます。
§1.0 移行前のファイルの確認
旧設定方式では一つの大きな設定用オブジェクトで全体の設定を行い、さらにその中のoverridesプロパティでfilesごとの設定を行っていましたが、flat config ではfilesごとに設定用オブジェクトを分割し、それらを配列に順に格納することで設定を行います。
つまり、flat config は旧設定方式におけるoverridesプロパティのみで設定するイメージです。
移行前の設定ファイルはこんな感じです。
.eslintignoreファイルを用いず、ignorePatternsで除外ファイルを設定しています。
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const isProduction = () => process.env.NODE_ENV === 'production';
/** @type {import('eslint').Linter.Config} */
module.exports = {
root: true,
reportUnusedDisableDirectives: true,
ignorePatterns: [
'.svelte-kit/',
'.vercel/', // adapter-vercel output dir
'.vercel_build_output/', // old output dir
'static/',
'build/',
'coverage/', // vitest coverage
'vitest.config.ts.timestamp*', // vite temp files
'node_modules/'
],
plugins: ['@typescript-eslint'],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/strict-type-checked',
'plugin:@typescript-eslint/stylistic-type-checked',
'prettier',
'plugin:svelte/all',
'plugin:svelte/prettier'
],
parser: '@typescript-eslint/parser',
parserOptions: {
sourceType: 'module',
ecmaVersion: 'latest',
project: './tsconfig.eslint.json',
extraFileExtensions: ['.svelte']
},
env: {
browser: true,
es2022: true,
node: true
},
rules: {
'no-console': isProduction() ? 'error' : 'off',
eqeqeq: ['error', 'always', { null: 'ignore' }],
'no-duplicate-imports': ['error', { includeExports: true }],
'no-restricted-imports': [
'error',
{ patterns: [{ group: ['../*', 'src/lib/*'], message: 'use `$lib/*` instead' }] }
],
'no-trailing-spaces': 'warn',
'no-unused-expressions': 'error',
'no-var': 'error',
'prefer-const': 'error',
'svelte/no-reactive-reassign': ['error', { props: true }],
'svelte/block-lang': ['error', { script: 'ts', style: null }],
'svelte/no-inline-styles': 'off',
'svelte/no-unused-class-name': 'warn',
'svelte/no-useless-mustaches': 'warn',
'svelte/no-restricted-html-elements': 'off',
'svelte/require-optimized-style-attribute': 'warn',
'svelte/sort-attributes': 'off',
'svelte/experimental-require-slot-types': 'off',
'svelte/experimental-require-strict-events': 'off',
'@typescript-eslint/array-type': ['error', { default: 'array-simple' }],
'@typescript-eslint/consistent-type-definitions': ['error', 'type'],
'@typescript-eslint/consistent-type-exports': 'error',
'@typescript-eslint/consistent-type-imports': 'error',
'@typescript-eslint/explicit-function-return-type': 'error',
'@typescript-eslint/explicit-member-accessibility': ['warn', { accessibility: 'no-public' }],
'@typescript-eslint/member-delimiter-style': 'warn',
'@typescript-eslint/method-signature-style': 'error',
camelcase: 'off',
'@typescript-eslint/naming-convention': [
'warn',
{
selector: 'default',
format: ['camelCase'],
leadingUnderscore: 'forbid',
trailingUnderscore: 'forbid'
},
{
selector: 'variable',
modifiers: ['global', 'const'],
format: ['camelCase', 'UPPER_CASE']
},
{
selector: 'parameter',
modifiers: ['unused'],
format: ['camelCase'],
leadingUnderscore: 'require',
trailingUnderscore: 'allow'
},
{
selector: 'memberLike',
modifiers: ['private'],
format: ['camelCase'],
leadingUnderscore: 'require'
},
{
selector: 'memberLike',
modifiers: ['protected'],
format: ['camelCase'],
leadingUnderscore: 'require'
},
{
selector: 'typeLike',
format: ['PascalCase']
},
{
// for non-exported functions
selector: 'function',
modifiers: ['global'],
format: ['camelCase'],
leadingUnderscore: 'require'
},
{
selector: 'function',
modifiers: ['exported', 'global'],
format: ['camelCase'],
leadingUnderscore: 'forbid'
}
],
'@typescript-eslint/no-import-type-side-effects': 'error',
'@typescript-eslint/no-require-imports': 'error',
'@typescript-eslint/no-unnecessary-qualifier': 'error',
'@typescript-eslint/no-unsafe-unary-minus': 'error',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-useless-empty-export': 'error',
'@typescript-eslint/prefer-enum-initializers': 'error',
'@typescript-eslint/prefer-readonly': 'error',
// '@typescript-eslint/prefer-readonly-parameter-types': 'error',
'@typescript-eslint/prefer-regexp-exec': 'error',
'@typescript-eslint/promise-function-async': 'error',
'@typescript-eslint/require-array-sort-compare': 'error',
'@typescript-eslint/switch-exhaustiveness-check': 'error',
'@typescript-eslint/non-nullable-type-assertion-style': 'off',
'@typescript-eslint/unbound-method': 'off'
},
overrides: [
{
files: ['*.svelte'],
parser: 'svelte-eslint-parser',
parserOptions: { parser: '@typescript-eslint/parser' },
rules: {
'no-trailing-spaces': 'off',
'svelte/no-trailing-spaces': ['warn', { skipBlankLines: false, ignoreComments: false }]
}
},
{
files: ['*.js', '*.cjs'],
rules: { '@typescript-eslint/explicit-function-return-type': 'off' }
},
{
files: ['*.cjs'],
rules: { '@typescript-eslint/no-require-imports': 'off' }
},
{
files: ['./*.config.*', '.eslintrc.cjs'],
rules: { '@typescript-eslint/naming-convention': 'off' }
}
]
};
このようにoverridesしている項目が複数あると flat config によって設定ファイルの記述が簡潔になることが期待できます。
§1.1 全体に適用するオプションの移行
はじめに、旧設定方式におけるignorePatternsのような全体に作用するオプションを移行します。
-
rootオプションはなくなりました -
reportUnusedDisableDirectives(とnoInlineConfig)オプションはlinterOptionsにまとめられました -
ignorePatternsオプションはignoresオプションに名前を変え、.eslintignoreファイルは廃止されました - 全体に適用したい
ignoresオプションは、途中のプラグインの設定等で上書き (overrides) されてしまう可能性があるため、配列の末尾に追加するとよいです
§1.1 diff .eslintrc.cjs → eslint.config.js
eslint-disableコメントは、@types/nodeパッケージによってprocess.envに型がついたため不要になりました。
-// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const isProduction = () => process.env.NODE_ENV === 'production';
-/** @type {import('eslint').Linter.Config} */
-module.exports = {
+/** @type {import('eslint').Linter.FlatConfig} */
+export default [
- root: true,
- reportUnusedDisableDirectives: true,
- ignorePatterns: ['.svelte-kit/', /* 省略 */, 'node_modules/'],
plugins: ['@typescript-eslint'],
// 省略
-};
+ { linterOptions: { reportUnusedDisableDirectives: true } },
+ { ignores: ['.svelte-kit/', /* 省略 */, 'node_modules/'] }
+];
§1.2 プラグインおよび Sharable Configs の移行
続けて各種プラグイン、および、旧設定ファイルにおいてはextendsプロパティで指定していた sharable config の設定を移行していきます。
-
filesオプションで各設定の適用範囲を設定すべきです- 例えば、
tsEslint.strictTypeCheckedではfilesに['**/*.ts', '**/*.tsx', '**/*.mts', '**/*.cts']のみが設定されてしまうので、所望のファイルに対して適切に設定が反映されるように必ずfilesオプションを設定するようにします
- 例えば、
-
filesを指定する際は、glob syntax (*.ts, *.tsxなど) と minimatch syntax (**/*.ts, **/*.tsxなど) のどちらを用いるかを統一したほうがよいでしょう -
Sharable configs は JavaScript modules としてインポートする形式になりました。これにより、
eslint-plugin-*などの prefix は特別なものではなくなりました -
eslint:recommendedなどの predefined config は@eslint/jsパッケージに移行しました-
@eslint/js@8.57.0には型定義がない(node_modules/.cache/**/@eslint/jsに自動生成される)ため、@types/eslint__jsパッケージをインストールして ESLint に教えてあげますnpm i -D @types/eslint__js
-
-
plugin:@typescript-eslint/strict-type-checkedなどの typescript-eslint 由来の sharable configs は.configs.strictTypeCheckedなどとしてtypescript-eslintのメンバとしてエクスポートされることになりました(参考:Shared Config | typescript-eslint) -
plugin:svelte/allなどの eslint-plugin-svelte 由来の sharable configs はconfigs['flat/all]などとしてeslint-plugin-svelteのメンバとしてエクスポートされることになりました(参考:User Guide) -
前述したように flat config では配列内の後ろの要素で前の要素の設定を上書きするため、依然として設定の順番に気をつける必要はあります
§1.2 diff .eslintrc.cjs → eslint.config.js
私のプロジェクトには.eslintrc.cjsを除いて*.cjsファイルはなく、[*.js, *.ts, *.svelte]ファイルを ESLint できれば十分なため、filesはそのように設定しました。
+import js from '@eslint/js';
+import tsEslint from 'typescript-eslint';
+import prettier from 'eslint-config-prettier';
+import svelte from 'eslint-plugin-svelte';
const isProduction = () => process.env.NODE_ENV === 'production';
+/** @type {import('eslint').Linter.FlatConfigFileSpec[]} */
+const files = ['**/*.js', '**/*.ts', '**/*.svelte'];
/** @type {import('typescript-eslint').Config} */
export default [
- plugins: ['@typescript-eslint'],
- extends: [
- 'eslint:recommended',
- 'plugin:@typescript-eslint/strict-type-checked',
- 'plugin:@typescript-eslint/stylistic-type-checked',
- 'prettier',
- 'plugin:svelte/all',
- 'plugin:svelte/prettier'
- ],
+ ...[
+ js.configs.recommended,
+ ...tsEslint.configs.strictTypeChecked,
+ ...tsEslint.configs.stylisticTypeChecked,
+ prettier
+ ].map((config) => ({ ...config, files })),
+ ...[
+ ...svelte.configs['flat/all'],
+ ...svelte.configs['flat/prettier']
+ ].map((config) => ({ ...config, files: ['**/*.svelte'] })),
parser: '@typescript-eslint/parser',
// 省略
{ ignores: ['.svelte-kit/', /* 省略 */, 'node_modules/'] }
];
§1.3 パーサーオプションの移行
パーサー周りの設定を移行していきます。
- プラグインの場合と同様に必ず
filesオプションで各設定の適用範囲を設定します -
parserやparserOptions、そしてenvオプションはlanguageOptionsにまとめられ、envオプションはglobalsに名前を変えました -
languageOptions.globalsはenvとは違い、browserやnodeといった多数の設定をまとめたプロパティは廃止されました。globalsライブラリを使うとよいらしいです - 前述したように
overridesオプションはなくなり、flat config では配列内の後ろの要素で前の要素の設定を上書き (overrides) するため、依然として設定の順番に気をつける必要はあります
§1.3 diff .eslintrc.cjs → eslint.config.js
parserOptions.tsconfigRootDirを適切に設定しておくことで、TSConfig (parserOptions.project) を相対パスで指定できるようになります。
// 省略
+import svelteParser from 'svelte-eslint-parser';
+import globals from 'globals';
const isProduction = () => process.env.NODE_ENV === 'production';
/** @type {import('eslint').Linter.FlatConfigFileSpec[]} */
const files = ['**/*.js', '**/*.ts', '**/*.svelte'];
/** @type {import('typescript-eslint').Config} */
export default [
...[
js.configs.recommended,
...tsEslint.configs.strictTypeChecked,
...tsEslint.configs.stylisticTypeChecked,
prettier
].map((config) => ({ ...config, files })),
...[
...svelte.configs['flat/all'],
...svelte.configs['flat/prettier']
].map((config) => ({ ...config, files: ['**/*.svelte'] })),
- parser: '@typescript-eslint/parser',
- parserOptions: {
- sourceType: 'module',
- ecmaVersion: 'latest',
- project: './tsconfig.eslint.json',
- extraFileExtensions: ['.svelte']
- },
- env: {
- browser: true,
- es2023: true,
- node: true
- },
+ {
+ files,
+ languageOptions: {
+ parser: tsEslint.parser,
+ parserOptions: {
+ sourceType: 'module',
+ ecmaVersion: 'latest',
+ project: './tsconfig.eslint.json',
+ tsconfigRootDir: import.meta.dirname,
+ extraFileExtensions: ['.svelte']
+ },
+ globals: { ...globals.browser, ...globals.es2021, ...globals.node }
+ }
+ },
rules: {
// 省略
},
overrides: [
- {
- files: ['*.svelte'],
- parser: 'svelte-eslint-parser',
- parserOptions: { parser: '@typescript-eslint/parser' },
- rules: {
- 'no-trailing-spaces': 'off',
- 'svelte/no-trailing-spaces': ['warn', { skipBlankLines: false, ignoreComments: false }]
- }
- },
+ {
+ files: ['**/*.svelte'],
+ languageOptions: {
+ parser: svelteParser,
+ parserOptions: { parser: tsEslint.parser }
+ },
+ rules: {
+ 'no-trailing-spaces': 'off',
+ 'svelte/no-trailing-spaces': ['warn', { skipBlankLines: false, ignoreComments: false }]
+ }
+ },
// 省略
{ ignores: ['.svelte-kit/', /* 省略 */, 'node_modules/'] }
];
§1.4 ルール設定の移行
自分で設定したルールセットを順番に気をつけて移行します。
例えば、Svelte ファイルにのみ適用したいルールセットを JS, TS, Svelte ファイル全体に対して適用したいルールセットの設定のあとの要素にしてしまうと、思った結果になりません。
後ろの要素で上書き (overrides) されるためです。
§1.4 diff .eslintrc.cjs → eslint.config.js
私のプロジェクトには.eslintrc.cjsを除いて*.cjsファイルはなく、[*.js, *.ts, *.svelte]ファイルを ESLint できれば十分なため、filesはそのように設定しました。
またeslint.config.jsではrequire()を使わなくなったため、@typescript-eslint/no-require-importsルールを無効化する必要がなくなりました。
// 省略
export default [
...[
js.configs.recommended,
...tsEslint.configs.strictTypeChecked,
...tsEslint.configs.stylisticTypeChecked,
prettier
].map((config) => ({ ...config, files })),
...[
...svelte.configs['flat/all'],
...svelte.configs['flat/prettier']
].map((config) => ({ ...config, files: ['**/*.svelte'] })),
{
files,
languageOptions: {
parser: tsEslint.parser,
parserOptions: {
sourceType: 'module',
ecmaVersion: 'latest',
project: './tsconfig.eslint.json',
extraFileExtensions: ['.svelte']
},
globals: { ...globals.browser, ...globals.es2021, ...globals.node }
- }
+ },
+ rules: {
+ 'no-console': isProduction() ? 'error' : 'off',
+ // 省略
+ '@typescript-eslint/unbound-method': 'off'
+ }
},
- rules: {
- 'no-console': isProduction() ? 'error' : 'off',
- // 省略
- '@typescript-eslint/unbound-method': 'off'
- },
- overrides: [
{
files: ['**/*.svelte'],
languageOptions: {
parser: svelteParser,
parserOptions: { parser: tsEslint.parser }
},
rules: {
'no-trailing-spaces': 'off',
'svelte/no-trailing-spaces': ['warn', { skipBlankLines: false, ignoreComments: false }]
}
},
- {
- files: ['*.js', '*.cjs'],
- rules: { '@typescript-eslint/explicit-function-return-type': 'off' }
- },
- {
- files: ['*.cjs'],
- rules: { '@typescript-eslint/no-require-imports': 'off' }
- },
- {
- files: ['./*.config.*', '.eslintrc.cjs'],
- rules: { '@typescript-eslint/naming-convention': 'off' }
- }
- ]
+ {
+ files: ['**/*.js'],
+ rules: { '@typescript-eslint/explicit-function-return-type': 'off' }
+ },
+ {
+ files: ['**/*.config.*'],
+ rules: { '@typescript-eslint/naming-convention': 'off' }
+ },
{ linterOptions: { reportUnusedDisableDirectives: true } },
{ ignores: ['.svelte-kit/', /* 省略 */, 'node_modules/'] }
];
§1.5 filesごとに設定を変数にまとめる
Flat config では、旧設定方式におけるparserやextendsの名前解決を ESLint が行わずともよくなったため、すべてをモジュールとして設定を変数やファイルに分割したりすることができるようになりました。
すなわち、リファクタリングです!
Flat config ではリファクタリングができるようになりました。
後々設定を変更しやすいように、同じfilesの設定群でまとめておきます。
Step 0. リファクタリング前のeslint.config.jsの確認
前節 §1.1.3 までで、.eslintrc.cjsはeslint.config.jsとして flat config に移行されました。
ここまでの結果を確認しておきましょう。
eslint.config.jsはこんな感じ
import js from '@eslint/js';
import tsEslint from 'typescript-eslint';
import prettier from 'eslint-config-prettier';
import svelte from 'eslint-plugin-svelte';
import svelteParser from 'svelte-eslint-parser';
import globals from 'globals';
const isProduction = () => process.env.NODE_ENV === 'production';
/** @type {import('eslint').Linter.FlatConfigFileSpec[]} */
const files = ['**/*.js', '**/*.ts', '**/*.svelte'];
/** @type {import('typescript-eslint').Config} */
export default [
...[
js.configs.recommended,
...tsEslint.configs.strictTypeChecked,
...tsEslint.configs.stylisticTypeChecked,
prettier
].map((config) => ({ ...config, files })),
...[
...svelte.configs['flat/all'],
...svelte.configs['flat/prettier']
].map((config) => ({ ...config, files: ['**/*.svelte'] })),
{
files,
languageOptions: {
parser: tsEslint.parser,
parserOptions: {
sourceType: 'module',
ecmaVersion: 'latest',
project: './tsconfig.eslint.json',
extraFileExtensions: ['.svelte']
},
globals: { ...globals.browser, ...globals.es2021, ...globals.node }
},
rules: {
'no-console': isProduction() ? 'error' : 'off',
eqeqeq: ['error', 'always', { null: 'ignore' }],
'no-duplicate-imports': ['error', { includeExports: true }],
'no-restricted-imports': [
'error',
{ patterns: [{ group: ['../*', 'src/lib/*'], message: 'use `$lib/*` instead' }] }
],
'no-trailing-spaces': 'warn',
'no-unused-expressions': 'error',
'no-var': 'error',
'prefer-const': 'error',
'svelte/no-reactive-reassign': ['error', { props: true }],
'svelte/block-lang': ['error', { script: 'ts', style: null }],
'svelte/no-inline-styles': 'off',
'svelte/no-unused-class-name': 'warn',
'svelte/no-useless-mustaches': 'warn',
'svelte/no-restricted-html-elements': 'off',
'svelte/require-optimized-style-attribute': 'warn',
'svelte/sort-attributes': 'off',
'svelte/experimental-require-slot-types': 'off',
'svelte/experimental-require-strict-events': 'off',
'@typescript-eslint/array-type': ['error', { default: 'array-simple' }],
'@typescript-eslint/consistent-type-definitions': ['error', 'type'],
'@typescript-eslint/consistent-type-exports': 'error',
'@typescript-eslint/consistent-type-imports': 'error',
'@typescript-eslint/explicit-function-return-type': 'error',
'@typescript-eslint/explicit-member-accessibility': ['warn', { accessibility: 'no-public' }],
'@typescript-eslint/member-delimiter-style': 'warn',
'@typescript-eslint/method-signature-style': 'error',
camelcase: 'off',
'@typescript-eslint/naming-convention': [
'warn',
{
selector: 'default',
format: ['camelCase'],
leadingUnderscore: 'forbid',
trailingUnderscore: 'forbid'
},
{
selector: 'variable',
modifiers: ['global', 'const'],
format: ['camelCase', 'UPPER_CASE']
},
{
selector: 'parameter',
modifiers: ['unused'],
format: ['camelCase'],
leadingUnderscore: 'require',
trailingUnderscore: 'allow'
},
{
selector: 'memberLike',
modifiers: ['private'],
format: ['camelCase'],
leadingUnderscore: 'require'
},
{
selector: 'memberLike',
modifiers: ['protected'],
format: ['camelCase'],
leadingUnderscore: 'require'
},
{
selector: 'typeLike',
format: ['PascalCase']
},
{
// for non-exported functions
selector: 'function',
modifiers: ['global'],
format: ['camelCase'],
leadingUnderscore: 'require'
},
{
selector: 'function',
modifiers: ['exported', 'global'],
format: ['camelCase'],
leadingUnderscore: 'forbid'
}
],
'@typescript-eslint/no-import-type-side-effects': 'error',
'@typescript-eslint/no-require-imports': 'error',
'@typescript-eslint/no-unnecessary-qualifier': 'error',
'@typescript-eslint/no-unsafe-unary-minus': 'error',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-useless-empty-export': 'error',
'@typescript-eslint/prefer-enum-initializers': 'error',
'@typescript-eslint/prefer-readonly': 'error',
// '@typescript-eslint/prefer-readonly-parameter-types': 'error',
'@typescript-eslint/prefer-regexp-exec': 'error',
'@typescript-eslint/promise-function-async': 'error',
'@typescript-eslint/require-array-sort-compare': 'error',
'@typescript-eslint/switch-exhaustiveness-check': 'error',
'@typescript-eslint/non-nullable-type-assertion-style': 'off',
'@typescript-eslint/unbound-method': 'off'
}
},
{
files: ['**/*.svelte'],
languageOptions: {
parser: svelteParser,
parserOptions: { parser: tsEslint.parser }
},
rules: {
'no-trailing-spaces': 'off',
'svelte/no-trailing-spaces': ['warn', { skipBlankLines: false, ignoreComments: false }]
}
},
{
files: ['**/*.js'],
rules: { '@typescript-eslint/explicit-function-return-type': 'off' }
},
{
files: ['**/*.config.*'],
rules: { '@typescript-eslint/naming-convention': 'off' }
},
{ linterOptions: { reportUnusedDisableDirectives: true } },
{
ignores: [
'.svelte-kit/',
'.vercel/', // adapter-vercel output dir
'.vercel_build_output/', // old output dir
'static/',
'build/',
'coverage/', // vitest coverage
'vite.config.ts.timestamp*', // vite temp file
'node_modules/'
]
}
];
旧設定方式とそこまで変わっているわけではありません。
Flat config 移行前は気にしていませんでしが、これは関心 (files) が分離されていて読みにくいコードです。
Step 1. JS, TS, Svelte 共通の設定を1か所にまとめる
JS, TS, Svelte 共通の設定をdefaultConfigとして1か所にまとめます。
- プラグインの設定に上書きされないように、
languageOptions等の自分で設定するものは配列の末尾にします
Step 1 diff eslint.config.js
svelte/*系のルールは Svelte の設定のほうに移動しました。
また flat config 配列の型定義を各変数ごとに使うので、FlatConfig型として@typedefしました。
import js from '@eslint/js';
import tsEslint from 'typescript-eslint';
import prettier from 'eslint-config-prettier';
import svelte from 'eslint-plugin-svelte';
import svelteParser from 'svelte-eslint-parser';
import globals from 'globals';
+
+/** @typedef {import('@typescript-eslint/utils').TSESLint.FlatConfig.Config} FlatConfig */
const isProduction = () => process.env.NODE_ENV === 'production';
-/** @type {import('eslint').Linter.FlatConfigFileSpec[]} */
-const files = ['**/*.js', '**/*.ts', '**/*.svelte'];
+/** @type {FlatConfig[]} */
+const defaultConfigWithoutExtensions = [
+ js.configs.recommended,
+ ...tsEslint.configs.strictTypeChecked,
+ ...tsEslint.configs.stylisticTypeChecked,
+ prettier,
+ {
+ languageOptions: {
+ parser: tsEslint.parser,
+ parserOptions: {
+ sourceType: 'module',
+ ecmaVersion: 2023,
+ project: './tsconfig.eslint.json',
+ tsconfigRootDir: import.meta.dirname,
+ extraFileExtensions: ['.svelte']
+ },
+ globals: { ...globals.browser, ...globals.es2021, ...globals.node }
+ },
+ rules: {
+ 'no-console': isProduction() ? 'error' : 'off',
+ // 省略
+ 'prefer-const': 'error',
+
+ '@typescript-eslint/array-type': ['error', { default: 'array-simple' }],
+ // 省略
+ '@typescript-eslint/unbound-method': 'off'
+ }
+ }
+];
-/** @type {import('typescript-eslint').Config} */
+/** @type {FlatConfig[]} */
export default [
-- ...[
-- js.configs.recommended,
-- ...tsEslint.configs.strictTypeChecked,
-- ...tsEslint.configs.stylisticTypeChecked,
-- prettier
-- ].map((config) => ({ ...config, files })),
+ ...defaultConfigWithoutExtensions.map(
+ (config) => ({ ...config, files: ['**/*.js', '**/*.ts', '**/*.svelte'] })
+ ),
...[
...svelte.configs['flat/all'],
...svelte.configs['flat/prettier']
].map((config) => ({ ...config, files: ['**/*.svelte'] })),
- {
- files,
- languageOptions: {
- parser: tsEslint.parser,
- parserOptions: {
- sourceType: 'module',
- ecmaVersion: 'latest',
- project: './tsconfig.eslint.json',
- extraFileExtensions: ['.svelte']
- },
- globals: { ...globals.browser, ...globals.es2021, ...globals.node }
- },
- rules: {
- 'no-console': isProduction() ? 'error' : 'off',
- // 省略
- 'prefer-const': 'error',
-
- 'svelte/no-reactive-reassign': ['error', { props: true }],
- // 省略
- 'svelte/experimental-require-strict-events': 'off',
-
- '@typescript-eslint/array-type': ['error', { default: 'array-simple' }],
- // 省略
- '@typescript-eslint/unbound-method': 'off'
- }
- },
{
files: ['**/*.svelte'],
languageOptions: {
parser: svelteParser,
parserOptions: { parser: tsEslint.parser }
},
rules: {
+ 'svelte/no-reactive-reassign': ['error', { props: true }],
+ // 省略
+ 'svelte/experimental-require-strict-events': 'off',
'no-trailing-spaces': 'off',
'svelte/no-trailing-spaces': ['warn', { skipBlankLines: false, ignoreComments: false }]
}
},
{
files: ['**/*.js'],
rules: { '@typescript-eslint/explicit-function-return-type': 'off' }
},
{
files: ['**/*.config.*'],
rules: { '@typescript-eslint/naming-convention': 'off' }
},
{ linterOptions: { reportUnusedDisableDirectives: true } },
{ ignores: ['.svelte-kit/', /* 省略 */ 'node_modules/'] }
];
Step 2 Svelte の設定を一つの配列にまとめる
Svelte の設定をsvelteConfigとしてまとめます。
- プラグインの設定に上書きされないように、
languageOptions等の自分で設定するものは配列の末尾にします -
eslint-plugin-svelteはmodule 'eslint' { namespace Linter { interface RulesRecordを上書きしているため、JSDoc を設定することでsvelte/*ルールに型補完が利くようになります(参考:feat: add rule types by xiBread・Pull Request #735・sveltejs/eslint-plugin-svelte)
Step 2 diff eslint.config.js
import js from '@eslint/js';
import tsEslint from 'typescript-eslint';
import prettier from 'eslint-config-prettier';
import svelte from 'eslint-plugin-svelte';
import svelteParser from 'svelte-eslint-parser';
import globals from 'globals';
/** @typedef {import('@typescript-eslint/utils').TSESLint.FlatConfig.Config} FlatConfig */
const isProduction = () => process.env.NODE_ENV === 'production';
/** @type {FlatConfig[]} */
const defaultConfigWithoutExtensions = [
// 省略
];
+
+/** @type {FlatConfig[]} */
+const svelteConfigWithoutExtensions = [
+ ...svelte.configs['flat/all'],
+ ...svelte.configs['flat/prettier'],
+ {
+ languageOptions: {
+ parser: svelteParser,
+ parserOptions: { parser: tsEslint.parser }
+ },
+ /** @type {import('eslint').Linter.RulesRecord} */
+ rules: {
+ 'svelte/no-reactive-reassign': ['error', { props: true }],
+ // 省略
+ 'svelte/experimental-require-strict-events': 'off',
+ 'no-trailing-spaces': 'off',
+ 'svelte/no-trailing-spaces': ['warn', { skipBlankLines: false, ignoreComments: false }]
+ }
+ }
+].map((config) => ({ ...config, files: ['**/*.svelte'] }));
/** @type {FlatConfig[]} */
export default [
...defaultConfigWithoutExtensions.map(
(config) => ({ ...config, files: ['**/*.js', '**/*.ts', '**/*.svelte'] })
),
- ...[
- ...svelte.configs['flat/all'],
- ...svelte.configs['flat/prettier']
- ].map((config) => ({ ...config, files: ['**/*.svelte'] })),
+ ...svelteConfigWithoutExtensions.map(
+ (config) => ({ ...config, files: ['**/*.svelte'] })
+ ),
- {
- files: ['**/*.svelte'],
- languageOptions: {
- parser: svelteParser,
- parserOptions: { parser: tsEslint.parser }
- },
- rules: {
- 'svelte/no-reactive-reassign': ['error', { props: true }],
- // 省略
- 'svelte/experimental-require-strict-events': 'off',
- 'no-trailing-spaces': 'off',
- 'svelte/no-trailing-spaces': ['warn', { skipBlankLines: false, ignoreComments: false }]
- }
- },
{
files: ['**/*.js'],
rules: { '@typescript-eslint/explicit-function-return-type': 'off' }
},
{
files: ['**/*.config.*'],
rules: { '@typescript-eslint/naming-convention': 'off' }
},
{ linterOptions: { reportUnusedDisableDirectives: true } },
{ ignores: ['.svelte-kit/', /* 省略 */ 'node_modules/'] }
];
Step 3 その他の設定もまとめる
見た目を統一するため、ついでに他の設定もfilesごとにまとめておきます。
- 再三になりますが、設定の順番には十分気を付けましょう
Step 3 diff eslint.config.js
import js from '@eslint/js';
import tsEslint from 'typescript-eslint';
import prettier from 'eslint-config-prettier';
import svelte from 'eslint-plugin-svelte';
import svelteParser from 'svelte-eslint-parser';
import globals from 'globals';
/** @typedef {import('@typescript-eslint/utils').TSESLint.FlatConfig.Config} FlatConfig */
const isProduction = () => process.env.NODE_ENV === 'production';
/** @type {FlatConfig[]} */
const defaultConfigWithoutExtensions = [
// 省略
];
/** @type {FlatConfig[]} */
const svelteConfigWithoutExtensions = [
// 省略
];
+
+/** @type {FlatConfig} */
+const jsConfig = {
+ files: ['**/*.js'],
+ rules: { '@typescript-eslint/explicit-function-return-type': 'off' }
+};
+
+/** @type {FlatConfig} */
+const configConfig = {
+ files: ['**/*.config.*'],
+ rules: { '@typescript-eslint/naming-convention': 'off' }
+};
/** @type {FlatConfig[]} */
export default [
...defaultConfigWithoutExtensions.map(
(config) => ({ ...config, files: ['**/*.js', '**/*.ts', '**/*.svelte'] })
),
...svelteConfigWithoutExtensions.map(
(config) => ({ ...config, files: ['**/*.svelte'] })
),
- {
- files: ['**/*.js'],
- rules: { '@typescript-eslint/explicit-function-return-type': 'off' }
- },
+ jsConfig,
- {
- files: ['**/*.config.*'],
- rules: { '@typescript-eslint/naming-convention': 'off' }
- },
+ configConfig,
{ linterOptions: { reportUnusedDisableDirectives: true } },
{ ignores: ['.svelte-kit/', /* 省略 */ 'node_modules/'] }
];
§1.6 移行後のファイルの確認
ESLint 公式のマイグレーションガイドにしたがって移行した後の設定ファイルはこのようになりました。
§1.6 eslint.config.js
import js from '@eslint/js';
import tsEslint from 'typescript-eslint';
import prettier from 'eslint-config-prettier';
import svelte from 'eslint-plugin-svelte';
import svelteParser from 'svelte-eslint-parser';
import globals from 'globals';
/** @typedef {import('@typescript-eslint/utils').TSESLint.FlatConfig.Config} FlatConfig */
const isProduction = () => process.env.NODE_ENV === 'production';
/** @type {FlatConfig[]} */
const defaultConfigWithoutExtensions = [
js.configs.recommended,
...tsEslint.configs.strictTypeChecked,
...tsEslint.configs.stylisticTypeChecked,
prettier,
{
languageOptions: {
parser: tsEslint.parser,
parserOptions: {
sourceType: 'module',
ecmaVersion: 2023,
project: './tsconfig.eslint.json',
tsconfigRootDir: import.meta.dirname,
extraFileExtensions: ['.svelte']
},
globals: { ...globals.browser, ...globals.es2021, ...globals.node }
},
rules: {
'no-console': isProduction() ? 'error' : 'off',
eqeqeq: ['error', 'always', { null: 'ignore' }],
'no-duplicate-imports': ['error', { includeExports: true }],
'no-restricted-imports': [
'error',
{ patterns: [{ group: ['../*', 'src/lib/*'], message: 'use `$lib/*` instead' }] }
],
'no-trailing-spaces': 'warn',
'no-unused-expressions': 'error',
'no-var': 'error',
'prefer-const': 'error',
'@typescript-eslint/array-type': ['error', { default: 'array-simple' }],
'@typescript-eslint/consistent-type-definitions': ['error', 'type'],
'@typescript-eslint/consistent-type-exports': 'error',
'@typescript-eslint/consistent-type-imports': 'error',
'@typescript-eslint/explicit-function-return-type': 'error',
'@typescript-eslint/explicit-member-accessibility': ['warn', { accessibility: 'no-public' }],
'@typescript-eslint/member-delimiter-style': 'warn',
'@typescript-eslint/method-signature-style': 'error',
camelcase: 'off',
'@typescript-eslint/naming-convention': [
'warn',
{
selector: 'default',
format: ['camelCase'],
leadingUnderscore: 'forbid',
trailingUnderscore: 'forbid'
},
{
selector: 'variable',
modifiers: ['global', 'const'],
format: ['camelCase', 'UPPER_CASE']
},
{
selector: 'parameter',
modifiers: ['unused'],
format: ['camelCase'],
leadingUnderscore: 'require',
trailingUnderscore: 'allow'
},
{
selector: 'memberLike',
modifiers: ['private'],
format: ['camelCase'],
leadingUnderscore: 'require'
},
{
selector: 'memberLike',
modifiers: ['protected'],
format: ['camelCase'],
leadingUnderscore: 'require'
},
{
selector: 'typeLike',
format: ['PascalCase']
},
{
// for non-exported functions
selector: 'function',
modifiers: ['global'],
format: ['camelCase'],
leadingUnderscore: 'require'
},
{
selector: 'function',
modifiers: ['exported', 'global'],
format: ['camelCase'],
leadingUnderscore: 'forbid'
}
],
'@typescript-eslint/no-import-type-side-effects': 'error',
'@typescript-eslint/no-require-imports': 'error',
'@typescript-eslint/no-unnecessary-qualifier': 'error',
'@typescript-eslint/no-unsafe-unary-minus': 'error',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-useless-empty-export': 'error',
'@typescript-eslint/prefer-enum-initializers': 'error',
'@typescript-eslint/prefer-readonly': 'error',
// '@typescript-eslint/prefer-readonly-parameter-types': 'error',
'@typescript-eslint/prefer-regexp-exec': 'error',
'@typescript-eslint/promise-function-async': 'error',
'@typescript-eslint/require-array-sort-compare': 'error',
'@typescript-eslint/switch-exhaustiveness-check': 'error',
'@typescript-eslint/non-nullable-type-assertion-style': 'off',
'@typescript-eslint/unbound-method': 'off'
}
}
];
/** @type {FlatConfig[]} */
const svelteConfigWithoutExtensions = [
...svelte.configs['flat/all'],
...svelte.configs['flat/prettier'],
{
languageOptions: {
parser: svelteParser,
parserOptions: { parser: tsEslint.parser }
},
/** @type {import('eslint').Linter.RulesRecord} */
rules: {
'svelte/no-reactive-reassign': ['error', { props: true }],
'svelte/block-lang': ['error', { script: 'ts', style: null }],
'svelte/no-inline-styles': 'off',
'svelte/no-unused-class-name': 'warn',
'svelte/no-useless-mustaches': 'warn',
'svelte/no-restricted-html-elements': 'off',
'svelte/require-optimized-style-attribute': 'warn',
'svelte/sort-attributes': 'off',
'svelte/experimental-require-slot-types': 'off',
'svelte/experimental-require-strict-events': 'off',
'no-trailing-spaces': 'off',
'svelte/no-trailing-spaces': ['warn', { skipBlankLines: false, ignoreComments: false }]
}
}
];
/** @type {FlatConfig} */
const jsConfig = {
files: ['**/*.js'],
rules: { '@typescript-eslint/explicit-function-return-type': 'off' }
};
/** @type {FlatConfig} */
const configConfig = {
files: ['**/*.config.*'],
rules: { '@typescript-eslint/naming-convention': 'off' }
};
/** @type {FlatConfig[]} */
export default [
...defaultConfigWithoutExtensions.map(
(config) => ({ ...config, files: ['**/*.js', '**/*.ts', '**/*.svelte'] })
),
...svelteConfigWithoutExtensions.map(
(config) => ({ ...config, files: ['**/*.svelte'] })
),
jsConfig,
configConfig,
{ linterOptions: { reportUnusedDisableDirectives: true } },
{
ignores: [
'.svelte-kit/',
'.vercel/', // adapter-vercel output dir
'.vercel_build_output/', // old output dir
'static/',
'build/',
'coverage/', // vitest coverage
'vitest.config.ts.timestamp*', // vite temp files
'node_modules/'
]
}
];
filesごとに設定がまとまっていて、どのルールがどのファイルに適用されるのかがわかりやすくなった気がします。
§2 typescript-eslint のヘルパー関数 config(...) を使って Flat Config に移行する
§1 のまま終わってもよいのですが、defaultConfigとそのfiles設定の間に行数が空いてしまうのも、設定ファイルに.map()が出てくるのも複雑な気がします。
そういった問題に対処するため、typescript-eslint はヘルパー関数.config()を提供しています。
これを使ってさらに改善してみました。
§2 diff/after eslint.config.js
defaultConfigのextendsオプションに JSDoc 型定義を書いていますが、これはeslint-config-prettierの型定義がない(node_modules/.cache/**に自動生成される)ためです。
@types/eslint-config-prettierパッケージをインストールして ESLint に教えてあげてもよいでしょう。
npm i -D @types/eslint-config-prettier
diff eslint.config.js
import js from '@eslint/js';
import tsEslint from 'typescript-eslint';
import prettier from 'eslint-config-prettier';
import svelte from 'eslint-plugin-svelte';
import svelteParser from 'svelte-eslint-parser';
import globals from 'globals';
-/** @typedef {import('@typescript-eslint/utils').TSESLint.FlatConfig.Config} FlatConfig */
-
const isProduction = () => process.env.NODE_ENV === 'production';
-/** @type {FlatConfig[]} */
-const defaultConfigWithoutExtensions = [
- js.configs.recommended,
- ...tsEslint.configs.strictTypeChecked,
- ...tsEslint.configs.stylisticTypeChecked,
- prettier,
+const defaultConfig = tsEslint.config({
+ files: ['**/*.js', '**/*.ts', '**/*.svelte'],
+ /** @type {import('typescript-eslint').ConfigWithExtends['extends']} */
+ extends: [
+ js.configs.recommended,
+ ...tsEslint.configs.strictTypeChecked,
+ ...tsEslint.configs.stylisticTypeChecked,
+ prettier
+ ],
- {
- languageOptions: {
- // 省略
- },
- rules: {
- 'no-console': isProduction() ? 'error' : 'off',
- // 省略
- '@typescript-eslint/unbound-method': 'off'
- }
- }
+ languageOptions: {
+ // 省略
+ },
+ rules: {
+ 'no-console': isProduction() ? 'error' : 'off',
+ // 省略
+ '@typescript-eslint/unbound-method': 'off'
+ }
-];
+});
-/** @type {FlatConfig[]} */
-const svelteConfigWithoutExtensions = [
- ...svelte.configs['flat/all'],
- ...svelte.configs['flat/prettier'],
+const svelteConfig = tsEslint.config({
+ files: ['**/*.svelte'],
+ extends: [
+ ...svelte.configs['flat/all'],
+ ...svelte.configs['flat/prettier']
+ ],
- {
- languageOptions: {
- parser: svelteParser,
- parserOptions: { parser: tsEslint.parser }
- },
- /** @type {import('eslint').Linter.RulesRecord} */
- rules: {
- 'svelte/no-reactive-reassign': ['error', { props: true }],
- // 省略
- 'no-trailing-spaces': 'off',
- 'svelte/no-trailing-spaces': ['warn', { skipBlankLines: false, ignoreComments: false }]
- }
- }
+ languageOptions: {
+ parser: svelteParser,
+ parserOptions: { parser: tsEslint.parser }
+ },
+ /** @type {import('eslint').Linter.RulesRecord} */
+ rules: {
+ 'svelte/no-reactive-reassign': ['error', { props: true }],
+ // 省略
+ 'no-trailing-spaces': 'off',
+ 'svelte/no-trailing-spaces': ['warn', { skipBlankLines: false, ignoreComments: false }]
+ }
-];
+});
+/** @typedef {import('@typescript-eslint/utils').TSESLint.FlatConfig.Config} FlatConfig */
+
/** @type {FlatConfig} */
const jsConfig = {
// 省略
};
/** @type {FlatConfig} */
const configConfig = {
// 省略
};
/** @type {FlatConfig[]} */
export default [
- ...defaultConfigWithoutExtensions.map(
- (config) => ({ ...config, files: ['**/*.js', '**/*.ts', '**/*.svelte'] })
- ),
+ ...defaultConfig,
- ...svelteConfigWithoutExtensions.map(
- (config) => ({ ...config, files: ['**/*.svelte'] })
- ),
+ ...svelteConfig,
jsConfig,
configConfig,
{ linterOptions: { reportUnusedDisableDirectives: true } },
{ ignores: ['.svelte-kit/', /* 省略 */ 'node_modules/'] }
];
ヘルパー関数使用後の eslint.config.js
import js from '@eslint/js';
import tsEslint from 'typescript-eslint';
import prettier from 'eslint-config-prettier';
import svelte from 'eslint-plugin-svelte';
import svelteParser from 'svelte-eslint-parser';
import globals from 'globals';
const isProduction = () => process.env.NODE_ENV === 'production';
const defaultConfig = tsEslint.config({
files: ['**/*.js', '**/*.ts', '**/*.svelte'],
/** @type {import('typescript-eslint').ConfigWithExtends['extends']} */
extends: [
js.configs.recommended,
...tsEslint.configs.strictTypeChecked,
...tsEslint.configs.stylisticTypeChecked,
prettier
],
languageOptions: {
parser: tsEslint.parser,
parserOptions: {
sourceType: 'module',
ecmaVersion: 2023,
project: './tsconfig.eslint.json',
tsconfigRootDir: import.meta.dirname,
extraFileExtensions: ['.svelte']
},
globals: { ...globals.browser, ...globals.es2021, ...globals.node }
},
rules: {
'no-console': isProduction() ? 'error' : 'off',
eqeqeq: ['error', 'always', { null: 'ignore' }],
'no-duplicate-imports': ['error', { includeExports: true }],
'no-restricted-imports': [
'error',
{ patterns: [{ group: ['../*', 'src/lib/*'], message: 'use `$lib/*` instead' }] }
],
'no-trailing-spaces': 'warn',
'no-unused-expressions': 'error',
'no-var': 'error',
'prefer-const': 'error',
'@typescript-eslint/array-type': ['error', { default: 'array-simple' }],
'@typescript-eslint/consistent-type-definitions': ['error', 'type'],
'@typescript-eslint/consistent-type-exports': 'error',
'@typescript-eslint/consistent-type-imports': 'error',
'@typescript-eslint/explicit-function-return-type': 'error',
'@typescript-eslint/explicit-member-accessibility': ['warn', { accessibility: 'no-public' }],
'@typescript-eslint/member-delimiter-style': 'warn',
'@typescript-eslint/method-signature-style': 'error',
camelcase: 'off',
'@typescript-eslint/naming-convention': [
'warn',
{
selector: 'default',
format: ['camelCase'],
leadingUnderscore: 'forbid',
trailingUnderscore: 'forbid'
},
{
selector: 'variable',
modifiers: ['global', 'const'],
format: ['camelCase', 'UPPER_CASE']
},
{
selector: 'parameter',
modifiers: ['unused'],
format: ['camelCase'],
leadingUnderscore: 'require',
trailingUnderscore: 'allow'
},
{
selector: 'memberLike',
modifiers: ['private'],
format: ['camelCase'],
leadingUnderscore: 'require'
},
{
selector: 'memberLike',
modifiers: ['protected'],
format: ['camelCase'],
leadingUnderscore: 'require'
},
{
selector: 'typeLike',
format: ['PascalCase']
},
{
// for non-exported functions
selector: 'function',
modifiers: ['global'],
format: ['camelCase'],
leadingUnderscore: 'require'
},
{
selector: 'function',
modifiers: ['exported', 'global'],
format: ['camelCase'],
leadingUnderscore: 'forbid'
}
],
'@typescript-eslint/no-import-type-side-effects': 'error',
'@typescript-eslint/no-require-imports': 'error',
'@typescript-eslint/no-unnecessary-qualifier': 'error',
'@typescript-eslint/no-unsafe-unary-minus': 'error',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-useless-empty-export': 'error',
'@typescript-eslint/prefer-enum-initializers': 'error',
'@typescript-eslint/prefer-readonly': 'error',
// '@typescript-eslint/prefer-readonly-parameter-types': 'error',
'@typescript-eslint/prefer-regexp-exec': 'error',
'@typescript-eslint/promise-function-async': 'error',
'@typescript-eslint/require-array-sort-compare': 'error',
'@typescript-eslint/switch-exhaustiveness-check': 'error',
'@typescript-eslint/non-nullable-type-assertion-style': 'off',
'@typescript-eslint/unbound-method': 'off'
}
});
const svelteConfig = tsEslint.config({
files: ['**/*.svelte'],
extends: [
...svelte.configs['flat/all'],
...svelte.configs['flat/prettier']
],
languageOptions: {
parser: svelteParser,
parserOptions: { parser: tsEslint.parser }
},
/** @type {import('eslint').Linter.RulesRecord} */
rules: {
'svelte/no-reactive-reassign': ['error', { props: true }],
'svelte/block-lang': ['error', { script: 'ts', style: null }],
'svelte/no-inline-styles': 'off',
'svelte/no-unused-class-name': 'warn',
'svelte/no-useless-mustaches': 'warn',
'svelte/no-restricted-html-elements': 'off',
'svelte/require-optimized-style-attribute': 'warn',
'svelte/sort-attributes': 'off',
'svelte/experimental-require-slot-types': 'off',
'svelte/experimental-require-strict-events': 'off',
'no-trailing-spaces': 'off',
'svelte/no-trailing-spaces': ['warn', { skipBlankLines: false, ignoreComments: false }]
}
});
/** @typedef {import('@typescript-eslint/utils').TSESLint.FlatConfig.Config} FlatConfig */
/** @type {FlatConfig} */
const jsConfig = {
files: ['**/*.js'],
rules: { '@typescript-eslint/explicit-function-return-type': 'off' }
};
/** @type {FlatConfig} */
const configConfig = {
files: ['**/*.config.*'],
rules: { '@typescript-eslint/naming-convention': 'off' }
};
/** @type {FlatConfig[]} */
export default [
...defaultConfig,
...svelteConfig,
jsConfig,
configConfig,
{ linterOptions: { reportUnusedDisableDirectives: true } },
{
ignores: [
'.svelte-kit/',
'.vercel/', // adapter-vercel output dir
'.vercel_build_output/', // old output dir
'static/',
'build/',
'coverage/', // vitest coverage
'vitest.config.ts.timestamp*', // vite temp files
'node_modules/'
]
}
];
対象となるfilesと設定が同じオブジェクトに記述されることで、どのルールがどのファイルに適用されるのかが更にわかりやすくなった気がします。
§3 移行まとめ
typescript-eslintの提供するヘルパー関数.config()を使って flat config に移行しました。
どのfilesにどの設定・ルールが適用されるか非常にわかりやすくなったと感じます。
一度 flat config に移行してしまえば、ESLint の設定ファイルに詳しくなくとも設定変更作業ができそうです。
重要な変更点としては、
- 大オブジェクトのプロパティによる設定形式から、
filesごとに設定オブジェクトを作成して配列にする形式に- 旧
overridesオプションのイメージ
- 旧
- プラグインの解決を ESLint では行わなくなった
- ESLint プラグインの命名が自由になりました
-
@rushstack/eslint-patchの modern-module-resolution オプションは不要に
-
.eslintignoreファイルおよびignorePatternsオプションの廃止-
ignoresオプションに一本化(filesオプションとの競合に注意)
-
-
languageOptionsの追加-
parser,parserOptions,envを統合 -
envオプションはlanguageOptions.globalsになり、browsersやnodeなどのプロパティによる設定から、globalsパッケージを使っての設定に
-
-
linterOptionsの追加-
reportUnusedDisableDirectives,noInlineConfigを統合
-
などでしょうか。
これまでの設定方式では@rushstack/eslint-patchとoverridesオプション、require()/import()をフルに使わないとできなかったことが簡潔にできるようになり、黒魔術的な設定ファイルから別れを告げられるようになりました。
個人的には、sharable config をこれまで以上に簡易に作れるようになったのが嬉しいです。
最後にもう一度移行前と移行後のファイルを並べておきます。
移行前の設定ファイル .eslintrc.cjs
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const isProduction = () => process.env.NODE_ENV === 'production';
/** @type {import('eslint').Linter.Config} */
module.exports = {
root: true,
reportUnusedDisableDirectives: true,
ignorePatterns: [
'.svelte-kit/',
'.vercel/', // adapter-vercel output dir
'.vercel_build_output/', // old output dir
'static/',
'build/',
'coverage/', // vitest coverage
'vitest.config.ts.timestamp*', // vite temp files
'node_modules/'
],
plugins: ['@typescript-eslint'],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/strict-type-checked',
'plugin:@typescript-eslint/stylistic-type-checked',
'prettier',
'plugin:svelte/all',
'plugin:svelte/prettier'
],
parser: '@typescript-eslint/parser',
parserOptions: {
sourceType: 'module',
ecmaVersion: 'latest',
project: './tsconfig.eslint.json',
extraFileExtensions: ['.svelte']
},
env: {
browser: true,
es2022: true,
node: true
},
rules: {
'no-console': isProduction() ? 'error' : 'off',
eqeqeq: ['error', 'always', { null: 'ignore' }],
'no-duplicate-imports': ['error', { includeExports: true }],
'no-restricted-imports': [
'error',
{ patterns: [{ group: ['../*', 'src/lib/*'], message: 'use `$lib/*` instead' }] }
],
'no-trailing-spaces': 'warn',
'no-unused-expressions': 'error',
'no-var': 'error',
'prefer-const': 'error',
'svelte/no-reactive-reassign': ['error', { props: true }],
'svelte/block-lang': ['error', { script: 'ts', style: null }],
'svelte/no-inline-styles': 'off',
'svelte/no-unused-class-name': 'warn',
'svelte/no-useless-mustaches': 'warn',
'svelte/no-restricted-html-elements': 'off',
'svelte/require-optimized-style-attribute': 'warn',
'svelte/sort-attributes': 'off',
'svelte/experimental-require-slot-types': 'off',
'svelte/experimental-require-strict-events': 'off',
'@typescript-eslint/array-type': ['error', { default: 'array-simple' }],
'@typescript-eslint/consistent-type-definitions': ['error', 'type'],
'@typescript-eslint/consistent-type-exports': 'error',
'@typescript-eslint/consistent-type-imports': 'error',
'@typescript-eslint/explicit-function-return-type': 'error',
'@typescript-eslint/explicit-member-accessibility': ['warn', { accessibility: 'no-public' }],
'@typescript-eslint/member-delimiter-style': 'warn',
'@typescript-eslint/method-signature-style': 'error',
camelcase: 'off',
'@typescript-eslint/naming-convention': [
'warn',
{
selector: 'default',
format: ['camelCase'],
leadingUnderscore: 'forbid',
trailingUnderscore: 'forbid'
},
{
selector: 'variable',
modifiers: ['global', 'const'],
format: ['camelCase', 'UPPER_CASE']
},
{
selector: 'parameter',
modifiers: ['unused'],
format: ['camelCase'],
leadingUnderscore: 'require',
trailingUnderscore: 'allow'
},
{
selector: 'memberLike',
modifiers: ['private'],
format: ['camelCase'],
leadingUnderscore: 'require'
},
{
selector: 'memberLike',
modifiers: ['protected'],
format: ['camelCase'],
leadingUnderscore: 'require'
},
{
selector: 'typeLike',
format: ['PascalCase']
},
{
// for non-exported functions
selector: 'function',
modifiers: ['global'],
format: ['camelCase'],
leadingUnderscore: 'require'
},
{
selector: 'function',
modifiers: ['exported', 'global'],
format: ['camelCase'],
leadingUnderscore: 'forbid'
}
],
'@typescript-eslint/no-import-type-side-effects': 'error',
'@typescript-eslint/no-require-imports': 'error',
'@typescript-eslint/no-unnecessary-qualifier': 'error',
'@typescript-eslint/no-unsafe-unary-minus': 'error',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-useless-empty-export': 'error',
'@typescript-eslint/prefer-enum-initializers': 'error',
'@typescript-eslint/prefer-readonly': 'error',
// '@typescript-eslint/prefer-readonly-parameter-types': 'error',
'@typescript-eslint/prefer-regexp-exec': 'error',
'@typescript-eslint/promise-function-async': 'error',
'@typescript-eslint/require-array-sort-compare': 'error',
'@typescript-eslint/switch-exhaustiveness-check': 'error',
'@typescript-eslint/non-nullable-type-assertion-style': 'off',
'@typescript-eslint/unbound-method': 'off'
},
overrides: [
{
files: ['*.svelte'],
parser: 'svelte-eslint-parser',
parserOptions: { parser: '@typescript-eslint/parser' },
rules: {
'no-trailing-spaces': 'off',
'svelte/no-trailing-spaces': ['warn', { skipBlankLines: false, ignoreComments: false }]
}
},
{
files: ['*.js', '*.cjs'],
rules: { '@typescript-eslint/explicit-function-return-type': 'off' }
},
{
files: ['*.cjs'],
rules: { '@typescript-eslint/no-require-imports': 'off' }
},
{
files: ['./*.config.*', '.eslintrc.cjs'],
rules: { '@typescript-eslint/naming-convention': 'off' }
}
]
};
flat config 移行後の eslint.config.js
import js from '@eslint/js';
import tsEslint from 'typescript-eslint';
import prettier from 'eslint-config-prettier';
import svelte from 'eslint-plugin-svelte';
import svelteParser from 'svelte-eslint-parser';
import globals from 'globals';
const isProduction = () => process.env.NODE_ENV === 'production';
const defaultConfig = tsEslint.config({
files: ['**/*.js', '**/*.ts', '**/*.svelte'],
extends: [
js.configs.recommended,
...tsEslint.configs.strictTypeChecked,
...tsEslint.configs.stylisticTypeChecked,
prettier
],
languageOptions: {
parser: tsEslint.parser,
parserOptions: {
sourceType: 'module',
ecmaVersion: 2023,
project: './tsconfig.eslint.json',
tsconfigRootDir: import.meta.dirname,
extraFileExtensions: ['.svelte']
},
globals: { ...globals.browser, ...globals.es2021, ...globals.node }
},
rules: {
'no-console': isProduction() ? 'error' : 'off',
eqeqeq: ['error', 'always', { null: 'ignore' }],
'no-duplicate-imports': ['error', { includeExports: true }],
'no-restricted-imports': [
'error',
{ patterns: [{ group: ['../*', 'src/lib/*'], message: 'use `$lib/*` instead' }] }
],
'no-trailing-spaces': 'warn',
'no-unused-expressions': 'error',
'no-var': 'error',
'prefer-const': 'error',
'@typescript-eslint/array-type': ['error', { default: 'array-simple' }],
'@typescript-eslint/consistent-type-definitions': ['error', 'type'],
'@typescript-eslint/consistent-type-exports': 'error',
'@typescript-eslint/consistent-type-imports': 'error',
'@typescript-eslint/explicit-function-return-type': 'error',
'@typescript-eslint/explicit-member-accessibility': ['warn', { accessibility: 'no-public' }],
'@typescript-eslint/member-delimiter-style': 'warn',
'@typescript-eslint/method-signature-style': 'error',
camelcase: 'off',
'@typescript-eslint/naming-convention': [
'warn',
{
selector: 'default',
format: ['camelCase'],
leadingUnderscore: 'forbid',
trailingUnderscore: 'forbid'
},
{
selector: 'variable',
modifiers: ['global', 'const'],
format: ['camelCase', 'UPPER_CASE']
},
{
selector: 'parameter',
modifiers: ['unused'],
format: ['camelCase'],
leadingUnderscore: 'require',
trailingUnderscore: 'allow'
},
{
selector: 'memberLike',
modifiers: ['private'],
format: ['camelCase'],
leadingUnderscore: 'require'
},
{
selector: 'memberLike',
modifiers: ['protected'],
format: ['camelCase'],
leadingUnderscore: 'require'
},
{
selector: 'typeLike',
format: ['PascalCase']
},
{
// for non-exported functions
selector: 'function',
modifiers: ['global'],
format: ['camelCase'],
leadingUnderscore: 'require'
},
{
selector: 'function',
modifiers: ['exported', 'global'],
format: ['camelCase'],
leadingUnderscore: 'forbid'
}
],
'@typescript-eslint/no-import-type-side-effects': 'error',
'@typescript-eslint/no-require-imports': 'error',
'@typescript-eslint/no-unnecessary-qualifier': 'error',
'@typescript-eslint/no-unsafe-unary-minus': 'error',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-useless-empty-export': 'error',
'@typescript-eslint/prefer-enum-initializers': 'error',
'@typescript-eslint/prefer-readonly': 'error',
// '@typescript-eslint/prefer-readonly-parameter-types': 'error',
'@typescript-eslint/prefer-regexp-exec': 'error',
'@typescript-eslint/promise-function-async': 'error',
'@typescript-eslint/require-array-sort-compare': 'error',
'@typescript-eslint/switch-exhaustiveness-check': 'error',
'@typescript-eslint/non-nullable-type-assertion-style': 'off',
'@typescript-eslint/unbound-method': 'off'
}
});
const svelteConfig = tsEslint.config({
files: ['**/*.svelte'],
extends: [
...svelte.configs['flat/all'],
...svelte.configs['flat/prettier']
],
languageOptions: {
parser: svelteParser,
parserOptions: { parser: tsEslint.parser }
},
/** @type {import('eslint').Linter.RulesRecord} */
rules: {
'svelte/no-reactive-reassign': ['error', { props: true }],
'svelte/block-lang': ['error', { script: 'ts', style: null }],
'svelte/no-inline-styles': 'off',
'svelte/no-unused-class-name': 'warn',
'svelte/no-useless-mustaches': 'warn',
'svelte/no-restricted-html-elements': 'off',
'svelte/require-optimized-style-attribute': 'warn',
'svelte/sort-attributes': 'off',
'svelte/experimental-require-slot-types': 'off',
'svelte/experimental-require-strict-events': 'off',
'no-trailing-spaces': 'off',
'svelte/no-trailing-spaces': ['warn', { skipBlankLines: false, ignoreComments: false }]
}
});
/** @typedef {import('@typescript-eslint/utils').TSESLint.FlatConfig.Config} FlatConfig */
/** @type {FlatConfig} */
const jsConfig = {
files: ['**/*.js'],
rules: { '@typescript-eslint/explicit-function-return-type': 'off' }
};
/** @type {FlatConfig} */
const configConfig = {
files: ['**/*.config.*'],
rules: { '@typescript-eslint/naming-convention': 'off' }
};
/** @type {FlatConfig[]} */
export default [
...defaultConfig,
...svelteConfig,
jsConfig,
configConfig,
{ linterOptions: { reportUnusedDisableDirectives: true } },
{
ignores: [
'.svelte-kit/',
'.vercel/', // adapter-vercel output dir
'.vercel_build_output/', // old output dir
'static/',
'build/',
'coverage/', // vitest coverage
'vitest.config.ts.timestamp*', // vite temp files
'node_modules/'
]
}
];
主な参考資料
- Configuration Migration Guide - ESLint
- Flat Config への移行 Tips Flat Config導入完了! 新しいESLintの設定フォーマットを使ってみた|uhyo
- New Features - Flat Config Support Announcing typescript-eslint v7 | typescript-eslint
- New Config (
eslint.config.js) User Guide - eslint-plugin-svelte
Discussion