Open15

【2023年版】tsconfig.jsonを設定する

t_keshit_keshi

前置き

本スクラップでは、Reactプロジェクトにおいて、2023年現在におけるベストなtsconfig.jsonの設定を考えていく。
その際、参考にするのは、以下の3つである。

  • Next.js
  • Vite
  • サバイバルTypeScript

順に、それぞれ補足していく。

Next.js

Next.js公式ドキュメントのcreate-tsconfigのページにある通り、空のtsconfig.jsonを作っておくと、Next.jsは勝手にtsconfigを生成してくれる。

実際に生成してみると、tsconfigが以下のようになった。
(使用バージョンはnext: 13.4.4)

{
  "compilerOptions": {
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": false,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "incremental": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve"
  },
  "include": [
    "next-env.d.ts",
    "**/*.ts",
    "**/*.tsx"
  ],
  "exclude": [
    "node_modules"
  ]
}

Vite

Vite公式ドキュメントのガイドにしたがって、

yarn create vite my-react --template react-ts

を叩いた。
その際、生成されたtsconfigが以下のようになった。
(使用バージョンはvite: 4.3.9)

{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,

    /* Bundler mode */
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx",

    /* Linting */
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true
  },
  "include": ["src"],
  "references": [{ "path": "./tsconfig.node.json" }]
}
{
  "compilerOptions": {
    "composite": true,
    "skipLibCheck": true,
    "module": "ESNext",
    "moduleResolution": "bundler",
    "allowSyntheticDefaultImports": true
  },
  "include": ["vite.config.ts"]
}

サバイバルTypeScript

サバイバルTypeScriptの2020年版スクラッチから作るなら: フロントエンドの場合というページには、以下のtsconfigが記載されていた。

{
  "compilerOptions": {
    "target": "es2020",
    "module": "esnext",
    "lib": ["es2020", "dom"],
    "jsx": "react",
    "sourceMap": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "moduleResolution": "node",
    "baseUrl": "src",
    "esModuleInterop": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
  },
  "include": ["src/**/*"],
  "exclude": ["dist", "node_modules"],
  "compileOnSave": false
}

三者の比較

以下に、Next.js, Vite, サバイバルTypeScriptの三つを比較し、共通点/相違点をまとめる。
()内の記述は、設定がない場合に適応されるデフォルト値である。

共通点

設定項目 Next.js Vite サバイバルTypeScript
module ESNext ESNext ESNext
forceConsistentCasingInFileNames true 設定なし(true) true
compileOnSave 設定なし(false) 設定なし(false) false

相違点

設定項目 Next.js Vite サバイバルTypeScript
moduleResolution node bundler node
target 設定なし(ES3) ES2020 ES2020
lib ["dom", "dom.iterable", "esnext"] ["ES2020", "DOM", "DOM.Iterable"] ["es2020", "dom"]
esModuleInterop true 設定なし(false) true
baseUrl 設定なし(記載なし) 設定なし(記載なし) src
include ["next-env.d.ts", "*/.ts", "*/.tsx"] ["src"] ["src/**/*"]
exclude ["node_modules"] 設定なし(記載なし) ["dist", "node_modules"],
allowJs true 設定なし(false) 設定なし(false)
noEmit true true 設定なし(false)
sourceMap 設定なし(true) 設定なし(true) false
outDir 設定なし(記載なし) 設定なし(記載なし) ./dist
jsx preserve react-jsx react
strict false true true
skipLibCheck true true 設定なし(false)
resolveJsonModule true true 設定なし(false)
isolatedModules true true 設定なし(false)
useDefineForClassFields 設定なし(false) true 設定なし(true)
experimentalDecorators 設定なし(false) 設定なし(false) true
emitDecoratorMetadata 設定なし(false) 設定なし(false) true
noUnusedLocals 設定なし(false) true 設定なし(false)
noUnusedParameters 設定なし(false) true 設定なし(false)
noFallthroughCasesInSwitch 設定なし(false) true 設定なし(false)
t_keshit_keshi

module, moduleResolution

tsconfigを設定するときの主要な項目である。

module

出力されるJavaScriptがどのようにモジュールを読み込むか指定するオプション。
これは、フロントエンドでは、ESNextを使っておけばまず間違いないと思われる。
(バックエンドだと、commonjsが指定されることが多い)

moduleResolution

このオプションは、インポートが何を参照しているかということについて、コンパイラがどう判断するのかを設定できる。
基本的には、Nodeを設定しておくのが一般的だと思う。

しかし、TypeScript5.0から、このmoduleResolutionのオプションとして、新しくbunlderを指定できるようになった。
詳しくは、typescript-5-0.moduleresolution-bundlerを参照してほしい。
bundlerを指定するということは、「bundlerと同じ方法でimportを解決してほしい」という感じらしい。
「TypeScriptよ、bundlerを信じてくれ!」というわけである。
最近のviteは、moduleResolutionをbundlerにするのを推奨している。

https://twitter.com/youyuxi/status/1636551895002255362?lang=en

これは「viteの開発環境ではバンドルを行わないので、TypeScriptがbundlerと同じようにimportを解決してくれるとありがたい」みたいなことかと思う。
...たぶん。
(ぶっちけよくわかっていないので詳しい人がいたら教えてほしい)

ただ、これについてViteとNext.jsはまったく異なる仕組みになっている。
Next.jsでは、Webpackまたは、Turbopackを使うことになる。
WebpackもTurbopackも、事前にバンドルする。
したがって、tsconfigがnodeであろうとbundlerであろうと、Next.jsにおいてはあまり関係のない話みたいだ。
結局はバンドルされたあとのjsを参照するわけだから、typescriptもクソもないという感じである。
実際、Next.jsの環境でtsconfigをいじって、{ moduleResolution: "bundler"}にしたところで、開発サーバーを起動した時点で勝手に{ moduleResolution: "node"}に戻されてしまう。

t_keshit_keshi

strict

正直、Next.jsのデフォルトの設定は、甘すぎる気がしてならない。
Next.jsのデフォルトの設定は変更することを推奨したい。

strictオプションについて、サバイバルTypeScriptから説明を引用させていただく

このオプションはTypeScript4.4時点で次の8個のオプションをすべて有効にしていることと同じです。スクラッチから開発するのであれば有効にしておいて差し支えないでしょう。

  • noImplicitAny
  • strictNullChecks
  • strictFunctionTypes
  • strictBindCallApply
  • strictPropertyInitialization
  • noImplicitThis
  • useUnknownInCatchVariables
  • alwaysStrict

ここでは「スクラッチから開発するのであれば有効にしておいて差し支えないでしょう。」とあるが、これはどちらかというと穏健派の意見だと思われる。

敗北者のTypeScriptには、次のようなやや過激な主張もある。

TypeScriptの安全性は日進月歩で改善されており、時折新しい安全性チェックが導入されます。--strictNullChecksと同様、それらは後方互換性を崩さないために原則としてコンパイルオプションの形で導入されます。その結果、これまで紹介した2つ以外にも安全性のチェックを強化するコンパイルオプションがいくつも提供しています。これらを有効にしないということはそれだけTypeScriptの安全性に穴を開けることになりますから、それらのコンパイルオプションは当然全て有効にすべきです。
そこで、これらのコンパイルオプション(前述の2つも含めて)をまとめて有効化できる素晴らしいオプションがあります。それが--strictです。ということは、--strictオプションを有効化することが敗北者からの脱却の前提条件です。TypeScript側としても--strictオプションの利用を推奨しており、tsc --initによって生成されるデフォルトのtsconfig.jsonファイルは--strictオプションが有効化された状態となっています。
前述のような事情から、JavaScriptからTypeScriptへの移行案件の場合は--strictオプションをいきなり有効化するのは難しいでしょう。しかし、新規のTypeScript開発で--strictを有効にしないという選択はまず有り得ず、あったとすればそれは前述の負け犬根性の表れであると言わざるを得ません。敗北者のTypeScriptにすでに毒されています。コードを書かないことがバグを生まない最善の方法であることは広く知られていますが、--strictを無効化するというのはまっさらの安全性100%の状態からコードを書き始める前に安全性を50%くらいまで落とすという行為に他ならないのです。

正直なところ、自分もこの意見に同意する。
strict: falseになっているプロジェクトで、あまり進んで開発したいとは思わない。
もし仮に誰かがstrict: falseに変更することを提案したならば、たぶん反対すると思う。

t_keshit_keshi

jsx

jsxの項目は、React16以下はreactを、React17以上はreact-jsxを指定するの良さそうだ。
その理由については、React17で加わったJSXの新しい変換方式が関係している。
詳しくは、React17におけるJSXの新しい変換を理解するを参照してほしい。

ただし、Next.jsの場合は、必ずpreseveを指定する必要がある。
これは「JSXの変換はBabel(またはSWC)に任せます」ということのようだ。

t_keshit_keshi

target

targetについては、サバイバルTypeScriptに素晴らしくわかりやすい説明が書いてあるので、そのまま引用する。

TypeScriptは最終的にJavaScriptにコンパイルされます。このオプションはそのときにどのバージョンのJavaScript向けに出力するかといったものです。
targetを設定すれば、TypeScriptはそのtargetの時点で使用できるオブジェクト、関数の定義ファイルが読み込まれます。つまり、あまりにも古いバージョンのtargetを指定すると昨今当然のように使っているオブジェクトや関数を使うことができないかもしれません。
targetを最新にしても、動作する環境が古いままだと使うことはできません。TypeScriptはコーディング中はあたかもそのオブジェクト、関数があるかのように入力補完をしますが、実際に動くjsの実行環境がそのバージョンのオブジェクトや関数を持っているかどうかは別問題だからです。とはいえ構文に新たな記法が生まれた場合、生まれるより前のtargetに設定すると新たな記法で書いていたとしてもコンパイル時にそのtargetで有効な構文に変換してくれます。有名な例では関数の表記です。たとえば"target": "es5"を指定した場合は() => {}といった"target": "es2015"から使えるアロー関数などの構文をES5でも動くfunction() {}という形式にコンパイルしてくれます。

これもNext.jsを使う場合は、BabelやSWC側で設定するのであまり関係がないように思われる。

Page Router(Babel)の場合

  "presets": [
    [
      "next/babel",
      {
        "preset-env": {
          "useBuiltIns": false,
          "targets": "Chrome >= 60, Safari >= 10.1, iOS >= 10.3, Firefox >= 54, Edge >= 15"
        }
      }
    ]
  ]

AppRouter(SWC)の場合

AppRouterの場合は、もともと、

  • Chrome 64+
  • Edge 79+
  • Firefox 67+
  • Opera 51+
  • Safari 12+

というように対象がモダンブラウザだけに規定されている。
したがって、このようなカスタムの設定はできないし、する必要もなさそう。

https://nextjs.org/docs/architecture/supported-browsers

ということで、tsconfigのtargetについて、自分はあまりこだわらずES5にしている。

t_keshit_keshi

lib

これもサバイバルTypeScriptがわかりやすい。

使いたいtargetには使いたい機能がない、でも使いたい。そのような時はlibオプションを指定することで使うことができるようになります。
このような最新バージョンにはある、または現時点では実装には至っていないが提案中(proposal)である機能を取り入れて使えるようにする物を通称ポリフィルと言います。ポリフィルについてさらに詳しく知りたい方は、What is a polyfill? (この単語の創案者である Remy Sharp による記事)をご覧ください。
libは必ず指定する必要はありません。targetを指定すればそのtargetで使われているライブラリは自動的に追加されます。指定したtargetでは実装されていないライブラリや、必要がないライブラリを除外したいときに使います。
指定は必ずしも必要ないとは申しあげましたがNode.jsでは構文(syntax)のサポートよりもAPIのサポートが先に行われることがあるためtargetではまだサポートしていないがNode.jsで使えるようになっているAPIをlibを指定することによって使えるようにすることがあります。

targetはES3だけど、我々は最新のECMAScriptの構文が使いたい、みたいなときは指定してあげる必要がある。

domやdom.iterableは、windowやdocumentが型エラーにならないために必要そう。
esnextの指定は「最新のECMAScriptの構文が使えるよ」ということを意味し、これは現時点ではES2022を指定することと同義になる。

自分は最新のECMAScriptの構文が使いたい人間なので、Viteの場合でもデフォルトの設定を変更して、 ["dom","dom.iterable","esnext"]指定する場合が多い。

t_keshit_keshi

noUnusedLocals, noUnusedParameters, noFallthroughCasesInSwitch

これらはLintの関わる設定で、viteのデフォルト設定だけがviteになっている。
しかし、LintはESLintにやらせた方が、よりキメの細かい設定ができる。
したがって、個人的には、これらのオプションは特に指定せず、falseのままにしておくことを推奨したい。

t_keshit_keshi

sourceMap

sourceMapをtrueにすると、コンパイル後のコードに、次のようにsourcemapが付属してくる。

dist/isPlainObject.js
"use strict";
var isPlainObject = function (item) {
    return item !== null && typeof item === 'object' && item.constructor === Object;
};
//# sourceMappingURL= isPlainObject.js.map

コンパイル後の古い記法で書かれたJSは、けっこう読みにくいものだ。
そんなとき、sourcemapがあれば、自分の書いたコードだとどこの部分に該当するのかがわかり、デバッグしやすくなる。
これは、特にライブラリを作成する場合などには、trueにしておくと、ライブラリを使う側にとっても重宝するかもしれない。

しかし、ライブラリ以外のものを開発する分には、要らない気もする。
もっとうまいデバッグの方法は色々ありそうだ。

https://qiita.com/s_harada/items/3a06567c1e7d8ec8b178

それでも私はsourceMapがほしい...?

sourcemapがあれば、自分の書いたコードのどこの部分に当たるのかがわかる。
しかし、sourcemapを公開することは、ソースコードを公開することに似て、セキュリティ的なリスクがあるのではないだろうか。

この問題も、ViteやNext.jsなどのモダンなツールを使えば解決する。
例えば、Viteだと次のように設定できる

vite.config.ts
import { defineConfig } from "vite"
import react from "@vitejs/plugin-react"

export default defineConfig({
    plugins: [react()],
    build: {
      sourcemap: "hidden",
    }
})

Next.jsだと、

SourceMapは、開発中にデフォルトで有効になります。
運用ビルド中は、構成フラグで特にオプトインしない限り、クライアントでのソースの漏洩を防ぐために無効になります。

となっているらしい。
もしどうしても本番環境でもSourceMapを出したい場合は、

next.config.js
module.exports = {
  productionBrowserSourceMaps: true,
};

のようにすれば良さそうだが、あまりその必要性を感じたことはない。

t_keshit_keshi

noEmit

noEmitについて公式の説明には次のようにある。

JavaScriptソースコード、ソースマップ、宣言文などのコンパイラ出力ファイルを出力しないようにします。
このため、Babelやswcのような別のツールで、TypeScriptファイルをJavaScript環境で動作するファイルへと変換することができます。

モダンフロントエンドでは、Babelやswcを使うことが多い。
(これは、厳密にいうと逆で、Babelやswcを使うことがモダンフロントエンドの定義の一つと書いた方が正確かもしれない)

したがって、一般的にはnoEmitはtrueにしておく。
しかし、package.jsonの方で、

"scripts": {
    "build": "tsc --noEmit && vite build",
  },

のように書いても結局は同じなので、好きな方に書いても良いかもしれない。
自分は基本的にtsconfigの方に書くことにしている。

t_keshit_keshi

experimentalDecorators

TypeScriptの公式によれば、このオプションをtrueにすると次のように書けるようになる。

function LogMethod(
  target: any,
  propertyKey: string | symbol,
  descriptor: PropertyDescriptor
) {
  console.log(target);
  console.log(propertyKey);
  console.log(descriptor);
}
 
class Demo {
  @LogMethod
  public foo(bar: number) {
    // do nothing
  }
}
 
const demo = new Demo();

↑の@LogMethodが新しいデコレーターである。
ただ、個人的には、そもそもclassを使う機会があまりないので、どっちでもいいというか、比較的重要度は低いオプションだと思っている

ちなみに、Next.jsのドキュメントには、このoptionをtrueにすることを推奨する旨が書かれていた。
自分もこれにしたがって、いつもtrueにしている。

t_keshit_keshi

isolatedModules

これもサバイバルTypeScriptがわかりやすい。

https://typescriptbook.jp/reference/tsconfig/isolatedModules#モジュールでないファイル

詳しい話は上記を読んでいただくとして、ここでは簡潔にコードだけ引用させていだたく。

someModule.ts
export type SomeType = any;
export function hello() {
  console.log("hello");
}
index.ts
import { SomeType, hello } from "./someModule";
 
export { SomeType, hello };

このようなコードがあると、Babelは、SomeTypeが値なのか型なのか判断できない。
isolatedModulesをtrueにすると、このような問題に対して警告を出してくれる。

サバイバルTypeScriptには、

ReactやNext.jsの雛形生成ツールによって作成されたtsconfig.jsonでは、isolatedModulesが有効化されています。これは、ReactやNextが内部でBabelを使用しているためです。isolatedModulesをfalseに変えてしまうとビルドできなくなる可能性があるため、設定を変更しないようにしましょう。

とある。
この通りで、モダンフロントエンドにおいては、true一択と考えて間違いないと思われる。

t_keshit_keshi

include, exclude

include, excludeを使うと、どのファイルをTypeScriptの対象とするかを指定できる。
例えば、プロジェクトが、

vite-projext-root
 ┣ src
 ┣ bff
 ┃  ┗ tsconfig.json
 ┗ tsconfig.json

のような構成になっている場合、TypeScriptがbffディレクトリをもその対象に含めてしまうと、非常に無駄が多い。
そういう場合には、include: ["src"]とするかexclude: ["bff"]のようにすると良さそう。

ライブラリを書く人は、includeやexcludeの設定をいじる機会が多いかもしれない。
しかし、ライブラリを書く以外のケースでは、基本的にviteやNext.jsのデフォルトのままで問題ないと思われる。

t_keshit_keshi

Next.js AppRouterに関する補足

Next.js AppRouterを使っている場合、next devしたときに、tsconfigに勝手に以下のプラグインが追加される。

{
 "compilerOptions": {
    "plugins": [
      {
        "name": "next"
      }
    ]
}

これはdynamicParamsrevalidateなどのAppRouter独自の構文に対して、以下のように型チェックをしてくれる効果がある。

plugin-app-router

さすがNext.js...!

t_keshit_keshi

resolveJsonModule

このオプションがtrueだと、jsonファイルがインポートできるようになる。
falseだと、

import dummyData  from './dummyData.json'

みたいな構文はエラーになる。
上の例のように、開発初期のプロジェクトで、まだAPIが出来上がってない場合、dummyDataをjson形式でフロント側で定義しておくことが多い。
したがって、json形式もimportできるようにしておきたい気持ちはわかる。

だが、個人的には、そう思わない。
フロントエンド側で、このようなdummyDataを量産させてしまうと、あとでいざAPIの繋ぎ込みを行おう、となったときに地獄をみることになる。

したがって、どうしてもフロントエンドから先に作らなければならない場合は、jsonを書くのではなくて、MSWなどを使った方があとで困らなくて済む。

このような理由で、自分は"resolveJsonModule": falseとするのもありだ。

と思ったが、よく考えたらこれも縛りたいならESLintの方で縛った方が、より柔軟な設定が効きやすい。
例えばだが、.eslintrc.jsにこんな設定を加えてはどうか。

.eslintrc.js
rules: {
   "import/extensions": ["warn", "always", { json: "never" }]
}
t_keshit_keshi

まとめ

ということで長くなったが、まとめると、tsconfigの内容は、ディレクトリの構成やReactのバージョン、その他多くの要素によって左右される。
一概に「これがベストだ!」というようなtsconfigの設定はなさそうだ。

しかし、基本的には、こんな感じにしておけばとりあえず無難であろう、ということであれば言えるかもしれない。
自分は以下のように設定することが多い。

Next.jsの場合

tsconfig.json
{
  "compilerOptions": {
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ],
    "allowJs": false
    "skipLibCheck": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "incremental": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "experimentalDecorators": true
  },
  "include": [
    "next-env.d.ts",
    "**/*.ts",
    "**/*.tsx"
  ],
  "exclude": [
    "node_modules"
  ]
}

あとは、勝手に追加されたPluginに関する設定にそのまま従う。

Viteの場合

{
  "compilerOptions": {
    "target": "ES5",
    "useDefineForClassFields": true,
    "lib": ["ESNext", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,
    "experimentalDecorators": true,

    /* Bundler mode */
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx",

    /* Linting */
    "strict": true,
  },
  "include": ["src"],
  "references": [{ "path": "./tsconfig.node.json" }]
}