1. ホーム
  2. TypeScript

【TypeScript】tsconfig.json の paths・baseUrl でパスエイリアスを設定する方法|@/ で import を短く書く

Share

import { formatDate } from '../../../lib/date'; のような相対パスが増えてくると、ファイルを移動するたびに import を直すことになります。tsconfig.jsonpathsbaseUrl を設定すれば、これを import { formatDate } from '@/lib/date'; のように短く書けます。この記事では、パスエイリアスの基本的な書き方から、tsc が import パスを書き換えないという重要な性質、Next.js や Vite、Jest といったツール側で必要になる設定までを解説します。

相対パスの ../../ をエイリアスに置き換える

プロジェクトの階層が深くなると、相対パスには2つの問題が出てきます。1つは ../../../ がいくつ必要なのかが読んだだけでは分からないこと、もう1つはファイルを別のディレクトリへ移動した瞬間に、そのファイルが書いている import が全部ずれることです。

パスエイリアスは、この相対パスを「プロジェクトのルートから見た絶対的な名前」に置き換える仕組みです。@/lib/date と書けば、どのファイルから import しても指す先は同じなので、ファイルを移動しても import はそのままで済みます。設定するのは tsconfig.jsoncompilerOptions にある pathsbaseUrl の2つです。

paths と baseUrl の最小設定

src ディレクトリ以下を @/ で参照できるようにする、もっとも一般的な設定が次のものです。

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    // paths の基準になるディレクトリ(この tsconfig.json のある場所)
    "baseUrl": ".",
    "paths": {
      // 「@/ で始まる import」を src/ 以下に読み替える
      "@/*": ["src/*"]
    }
  },
  "include": ["src"]
}

この状態でファイル構成が src/lib/date.tssrc/features/report/monthly/summary.ts だとすると、深い階層からの import が次のように書けます。

src/features/report/monthly/summary.ts
// これまで: 階層が変わるたびに書き直しが必要
import { formatDate } from '../../../lib/date';

// エイリアス: どの階層から書いても同じ
import { formatDate } from '@/lib/date';

export function buildSummary(date: Date): string {
  return `${formatDate(date)} の集計`;
}

2つのオプションは名前が似ていますが、役割ははっきり分かれています。

設定役割
baseUrl相対パスでない import(@/lib/datelib/date のような書き方)を解決する基準ディレクトリ。paths に書いた解決先も、ここからの相対パスとして扱われる
pathsimport に書いた文字列を、別の場所に読み替えるための対応表。キーがパターン、値が解決先の配列
*(キー側)任意の文字列にマッチするワイルドカード。1つのパターンに1つだけ書ける
*(値側)キー側の * にマッチした部分がそのまま入る。@/lib/date なら lib/date が入る
値の配列解決先の候補。先頭から順に試し、最初に見つかったものが採用される

ここで気をつけたいのが、baseUrlpaths の基準になるだけでなく、それ単体でも import の解決方法を変えてしまうことです。たとえば "baseUrl": "./src" と書くと、paths を何も書かなくても import { formatDate } from 'lib/date'; のような書き方ができてしまいます。この形は npm パッケージの import と見分けが付かず、src 直下に react.ts のようなファイルを置いたときに紛らわしくなります。

TypeScript 4.1 以降は baseUrl なしで書ける

かつては paths を指定すると baseUrl も必須で、書かないと「Option ‘paths’ cannot be used without specifying ‘–baseUrl’ option.」というエラーになりました。TypeScript 4.1 でこの制約がなくなり、paths を単独で書けるようになっています。この場合、解決先のパスは tsconfig.json のある場所を基準に解決されます。

tsconfig.json
{
  "compilerOptions": {
    "moduleResolution": "bundler",
    // baseUrl なし。解決先は tsconfig.json のある場所からの相対パス
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["src"]
}

前述のとおり baseUrl には「相対パスでない import を全部そのディレクトリから探す」という副作用があるため、エイリアスだけが目的なら baseUrl を書かないこちらの形が安全です。エディタの自動 import が意図しないパスを提案しにくくなる利点もあります。ただし後述するように、Next.js のようにフレームワーク側が tsconfig.json を読む場合はそのバージョンの対応状況にも左右されるので、既存のプロジェクトでは無理に消さず、うまく動いている書き方を維持して構いません。

複数のエイリアスと候補の指定

paths には複数のパターンを並べられます。@/* ひとつで済ませても構いませんし、よく使うディレクトリに個別の名前を付けることもできます。

tsconfig.json
{
  "compilerOptions": {
    "paths": {
      "@/*": ["./src/*"],
      "@components/*": ["./src/components/*"],

      // 候補は先頭から順に試され、最初に見つかったものが使われる
      "@shared/*": ["./packages/shared/src/*", "./packages/shared/dist/*"],

      // ワイルドカードなしで、特定のモジュール名だけを差し替えることもできる
      "legacy-utils": ["./vendor/legacy-utils/index.d.ts"]
    }
  }
}

ワイルドカードは1つのパターンにつき1つまでです。"@/*/helpers/*" のように2つ書くことはできません。また、より具体的なパターン(@components/*)と一般的なパターン(@/*)が両方マッチしうる場合は、ワイルドカード以外の部分が長い、より具体的なパターンが優先されます。

エイリアス名の付け方で1つ注意したいのは、@ で始まる名前が npm のスコープ付きパッケージ(@types/node など)と同じ見た目になることです。@/ のようにスラッシュを直後に置く形なら実在のスコープ名と衝突しませんが、@components のような名前は、将来同名のパッケージを入れたときに紛らわしくなります。プロジェクト内を指すことを明示したい場合は ~/* を使う流儀もあります。

tsc は import パスを書き換えない

ここがもっとも誤解されやすいところです。paths は TypeScript が型チェックのときにファイルの場所を探すための設定であり、コンパイル後の JavaScript の import 文はそのまま残ります。 実際に tsc でビルドすると、出力された JS は次のようになります。

dist/main.js(tsc の出力)
// エイリアスのまま出力される。このまま node で実行すると
// Cannot find module '@/lib/date' で落ちる
import { hi } from '@/lib/date';
console.log(hi);

型チェックは通るのに実行時だけ落ちる、という現象の正体はこれです。エディタ上ではエラーが出ず、tsc --noEmit も成功するので、原因が分かりにくいのが厄介なところです。実行時にもエイリアスを解決させるには、TypeScript の外側で手当てが必要になります。

バンドラに同じエイリアスを教える

Vite や webpack でバンドルする構成なら、バンドラ側にも同じ対応表を書きます。バンドルの過程で実際のファイルへ解決されるので、出力された JS にエイリアスは残りません。Web アプリで一番よく使われるのがこの方法です。

ビルド後にパスを書き換える

Node.js 向けのサーバーコードなど、バンドルせずに tsc の出力をそのまま実行する構成では、tsc-alias のようなツールを使って、出力後の JS のエイリアスを相対パスへ置換する方法があります。tsc && tsc-alias のようにビルドスクリプトを2段にして使います。

Node.js の imports フィールドを使う

ビルド後の書き換えを避けたい場合は、TypeScript の paths ではなく Node.js 標準のサブパス import を使う手があります。package.jsonimports フィールドに # で始まる名前で対応表を書くと、Node.js 自身がその名前を解決してくれます。TypeScript も moduleResolutionnode16 / nodenext / bundler であればこのフィールドを読むため、型チェックと実行時の解決が1つの設定で揃います。

package.json
{
  "name": "my-app",
  "type": "module",
  "imports": {
    "#lib/*": "./dist/lib/*.js"
  }
}

この場合、import 側は import { formatDate } from '#lib/date'; と書きます。名前が # 始まりに固定されるという制約はありますが、実行時の解決を Node.js に任せられるのが利点です。

フレームワーク・ツール別に必要な設定

実際のプロジェクトでは、TypeScript のほかにバンドラやテストランナー、リンターがそれぞれ独自にモジュールを解決しています。tsconfig.json だけ直しても、テストだけ「モジュールが見つからない」で失敗する、といったことが起きるのはこのためです。主なツールの対応は次のとおりです。

ツール必要な対応
Next.jstsconfig.jsonbaseUrlpaths をそのまま読んで解決するため、追加設定は不要(create-next-app は既定で @/* を設定する)
Vite / Vitestvite.config.tsresolve.alias に同じ対応を書くか、vite-tsconfig-paths プラグインを入れる
webpackresolve.alias に同じ対応を書く(tsconfig-paths-webpack-plugin でも可)
JestmoduleNameMapper に正規表現で対応を書く
ts-nodetsconfig-paths を併用する(ts-node -r tsconfig-paths/register
ESLintimport 系のルールを使う場合は eslint-import-resolver-typescript を設定する

Vite の場合は、resolve.alias に絶対パスを書きます。@ という接頭辞そのものを対応表のキーにする点に注意してください。

vite.config.ts
import { defineConfig } from 'vite';
import { fileURLToPath, URL } from 'node:url';

export default defineConfig({
  resolve: {
    alias: {
      // '@' で始まる import を src ディレクトリの絶対パスに読み替える
      '@': fileURLToPath(new URL('./src', import.meta.url)),
    },
  },
});

Jest の moduleNameMapper は正規表現とキャプチャで書きます。<rootDir> は Jest が用意しているプレースホルダーで、設定ファイルのあるディレクトリに置き換わります。

jest.config.js
module.exports = {
  preset: 'ts-jest',
  moduleNameMapper: {
    // '@/lib/date' を プロジェクトルート/src/lib/date に読み替える
    '^@/(.*)$': '<rootDir>/src/$1',
  },
};

同じ対応表を複数のファイルに書くことになるため、エイリアスを増やしたときは tsconfig.json だけでなく、これらの設定もセットで更新する必要があります。エイリアスを増やしすぎると管理が面倒になるので、@/* の1本に絞っておくのが結局は楽だ、という判断も十分にありです。

エイリアスが解決されないとき

設定したはずのエイリアスが効かないときは、どの段階で失敗しているのかを切り分けると原因を絞り込めます。

エディタでは通るのに実行時やテストで落ちる

「Cannot find module ‘@/lib/date’」が実行時やテスト実行時にだけ出る場合、TypeScript の解決は成功しています。つまり tsconfig.json は正しく、落ちている側のツール(Node.js、Jest、バンドラ)がエイリアスを知らないだけです。前の章の表を見て、そのツール側の設定を追加してください。

エディタ上で赤線が出る

逆にエディタ上で「モジュールが見つかりません(ts2307)」と出る場合は、TypeScript が解決できていません。まず npx tsc --noEmit をターミナルで実行し、コマンドラインでも同じエラーになるか確認します。ターミナルでは通るのにエディタだけ赤い場合は、エディタが別の tsconfig.json を見ているか、TypeScript 言語サーバーが古い設定を保持している可能性が高いので、言語サーバーを再起動してみてください。

ターミナルでもエラーになるなら、baseUrl と解決先の組み合わせを確認します。"baseUrl": ".""paths": { "@/*": ["./src/*"] } のように、両方に基準を書いても解決先は baseUrl からの相対なので問題ありませんが、"baseUrl": "./src""paths": { "@/*": ["./src/*"] } のように書くと src/src/ を探しにいってしまいます。

出力先のディレクトリ構造が崩れる

エイリアスで src の外にあるファイル(モノレポの別パッケージなど)を参照すると、そのファイルもコンパイル対象に含まれます。その結果 outDir の中に想定外のディレクトリが増えたり、「File is not under ‘rootDir’」(ts6059)というエラーが出たりします。参照先を rootDir の中に収めるか、共有コードを独立したパッケージとして参照する構成に変えるのが根本的な対処です。

自動 import が相対パスのままになる

エイリアスを設定しても、VS Code の自動 import が ../../lib/date のような相対パスを挿入し続けることがあります。これは設定ミスではなく、エディタの好みの問題です。設定で typescript.preferences.importModuleSpecifiernon-relative(相対パス以外を優先)や shortest(短いほうを優先)にすると、エイリアスを使った形が挿入されるようになります。チームで揃えたい場合は .vscode/settings.json に書いてコミットしておくとよいでしょう。

まとめ

tsconfig.jsonpaths は import に書いた文字列を別の場所へ読み替える対応表で、baseUrl はその解決先の基準になるディレクトリです。"@/*": ["./src/*"] のように設定すれば、深い階層からでも @/lib/date という一定の書き方で参照でき、ファイルを移動しても import を直さずに済みます。TypeScript 4.1 以降は baseUrl を書かずに paths だけを指定でき、その場合は tsconfig.json の場所が基準になります。もっとも重要なのは、paths があくまで型チェック時の解決設定であり、tsc は出力する JavaScript の import パスを書き換えないという点です。実行時にも解決させるには、バンドラの resolve.aliastsc-alias による出力後の書き換え、あるいは Node.js の imports フィールドといった手段を併用します。Next.js のように tsconfig.json をそのまま読むフレームワークもあれば、Vite や Jest のように個別の設定が要るツールもあるので、エイリアスを増やしたときは関係する設定ファイルをまとめて更新してください。

参考ページ