1. ホーム
  2. TypeScript

【TypeScript】tsconfig.json の target・module・lib の使い方|出力する JavaScript のバージョンと読み込む型定義を指定する

Share

tsconfig.json を開いたときに、いちばん上のほうに並んでいるのが targetmodulelib の3つです。どれも「JavaScript のバージョン」に関係しそうな名前をしていますが、担当している仕事はまったく別です。この記事では、それぞれが何を決める設定なのかを区別しながら、3つの関係、Node.js やブラウザ向けの現実的な設定例、そして「target を上げたはずなのに Property 'at' does not exist が出る」といったエラーの見分け方までを解説します。

target・module・lib はそれぞれ別の仕事をしている

3つとも compilerOptions の中に書く設定ですが、影響する場所が違います。まずここを押さえておくと、後の設定例やエラーの原因がすっきり理解できます。

設定何を決めるか
target出力する JavaScript の文法のバージョン。アロー関数やクラス、async/await をそのまま残すか、古い書き方に変換するかが決まる
module出力する JavaScript のモジュール形式import/export をそのまま残すか、require/exports に変換するかが決まる
libコンパイル時に読み込む組み込み API の型定義documentArray.prototype.at といった名前を TypeScript が知っているかどうかが決まる

大きく分けると、targetmodule は「出力するコードをどう書くか」、lib は「型チェックのときに何を知っているか」の設定です。lib は出力される JavaScript の内容をいっさい変えません。逆に targetmodule は、型チェックの結果には(一部の例外を除いて)ほとんど関係しません。

target で出力する文法のバージョンを決める

target は、出力する JavaScript がどの ECMAScript の文法に従うかを指定します。ES5ES2015ES6 と同じ)、以降 ES2016 から年号ごとの値が並び、最後に ESNext があります。指定しなかった場合の既定値は ES5 です(tsc --init で生成される tsconfig.json には "target": "es2016" が書かれるので、そちらを見て混乱しないようにしてください)。

tsconfig.json
{
  "compilerOptions": {
    // 出力する JavaScript の文法バージョン
    "target": "ES2022"
  }
}

値ごとに「そのまま出力される文法」が変わります。よく使う値だけ挙げると次のようになります。

そのまま出力されるようになるもの
ES5ほぼ何も残らない。アロー関数・クラス・let/const・テンプレートリテラル・分割代入などがすべて古い書き方に変換される
ES2015クラス、アロー関数、let/const、テンプレートリテラル、ジェネレーター。async/await はまだ変換される
ES2017async/await
ES2020オプショナルチェーン ?. と null 合体演算子 ??
ES2022クラスフィールドと static ブロック。あわせて useDefineForClassFields の既定値が true になる
ESNextその時点の TypeScript が知っている最新の仕様。TypeScript を上げると指す内容が変わる

ダウンレベル変換の実際の出力

target を下げると、新しい文法が古い文法に書き換えられます。これをダウンレベル変換(downleveling)と呼びます。たとえば次のクラスを見てください。

src/counter.ts
export class Counter {
  count = 0;

  // アロー関数なので this がインスタンスに固定される
  increment = () => {
    this.count += 1;
  };
}

"target": "ES2015" 以上なら、class とアロー関数はそのまま残り、型注釈が消えるだけの素直な出力になります。ところが "target": "ES5" にすると、クラスは関数に、アロー関数は function 式に書き換えられます。this が変わってしまわないよう、_this という変数が用意されるのが特徴です。

dist/counter.js(target: ES5 の出力)
var Counter = /** @class */ (function () {
    function Counter() {
        var _this = this;
        this.count = 0;
        // アロー関数は function 式になり、this は _this 経由で参照される
        this.increment = function () {
            _this.count += 1;
        };
    }
    return Counter;
}());

変換の量がもっとも増えるのが async/await です。async 関数は ES2017 で標準化されたので、"target": "ES2017" 以上なら型注釈が外れるだけでそのまま出力されます。

src/main.ts
async function main(): Promise<void> {
  const res = await fetch('https://example.com');
  console.log(res.status);
}

これを "target": "ES5" でコンパイルすると、__awaiter__generator という2つのヘルパー関数がファイルの先頭に差し込まれ、関数の中身は switch による状態遷移に書き換えられます。

dist/main.js(target: ES5 の出力・抜粋)
// ヘルパーの中身は長いので省略。ファイルの先頭に自動で挿入される
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { /* 省略 */ };
var __generator = (this && this.__generator) || function (thisArg, body) { /* 省略 */ };

function main() {
    return __awaiter(this, void 0, void 0, function () {
        var res;
        return __generator(this, function (_a) {
            switch (_a.label) {
                case 0: return [4 /*yield*/, fetch('https://example.com')];
                case 1:
                    res = _a.sent();
                    console.log(res.status);
                    return [2 /*return*/];
            }
        });
    });
}

このヘルパーはファイルごとに挿入されるため、ファイル数が多いと出力全体が膨らみます。importHelperstrue にして tslib パッケージを依存に入れると、ヘルパーを毎回埋め込む代わりに tslib から import する形になり、出力サイズを抑えられます。とはいえ、いま新しく書くコードで ES5 まで下げる必要があるケースはほとんどありません。サポートしたい環境が決まっているなら、そこで動く範囲でできるだけ高い target を選ぶのが素直です。

module で出力するモジュール形式を決める

module は、import / export をどんな形式で出力するかを指定します。既定値は target に連動していて、targetES5 のときは CommonJS、それより新しいときは ES6ES2015)になります。つまり target を上げると、意図せず出力形式まで変わることがあるので、module は明示的に書いておくのが安全です。

出力される形式
CommonJSrequire()exports を使う Node.js の従来形式に変換する
ES2015 / ES6import / export をそのまま出力する
ES2020上記に加えて import.metaexport * as ns from '...' が使える
ES2022上記に加えてトップレベル await が使える
ESNext最新の ECMAScript モジュール仕様に追随する
Node16 / Node18 / NodeNextもっとも近い package.jsontype とファイル拡張子(.mts / .cts)から、ファイル単位で CommonJS か ES モジュールかを決める。Node.js の実際の挙動に合わせたい場合に使う
PreserveTypeScript 5.4 で追加。import / export をそのまま残し、import x = require('...') も CommonJS のまま出力する。変換をバンドラに任せる構成向けで、moduleResolution の既定値が Bundler になる
AMD / UMD / System古いモジュールローダー向けの形式。新規プロジェクトで選ぶ理由はほぼない

import が require に変換される例

いちばん違いが分かりやすいのが CommonJS です。次のファイルをコンパイルしてみます。

src/config.ts
import { readFile } from 'node:fs/promises';

export const configPath = './app.config.json';

export async function loadConfig(): Promise<string> {
  return readFile(configPath, 'utf8');
}

"module": "ESNext" なら importexport はそのままです。一方 "module": "CommonJS" にすると、importrequire() の呼び出しに、exportexports への代入に置き換わります。

dist/config.js(module: CommonJS の出力)
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.configPath = void 0;
exports.loadConfig = loadConfig;
const promises_1 = require("node:fs/promises");
exports.configPath = './app.config.json';
async function loadConfig() {
    return (0, promises_1.readFile)(exports.configPath, 'utf8');
}

細かい部分は TypeScript のバージョンによって変わりますが、要点は「import 文が消えて require() になる」ことです。出力先の環境が ES モジュールを期待しているのに CommonJS で出力してしまうと、ここで食い違いが起きて実行時エラーになります。

moduleResolution との違い

名前が似ている moduleResolution は、まったく別の設定です。module が「出力の形式」を決めるのに対し、moduleResolution は「import 'foo' と書かれたときに、どのファイルを探しに行くか」というルールを決めます。node_modules をたどる順番や、package.jsonexports フィールドを読むかどうか、拡張子を省略できるかどうかがこれで変わります。

既定値は module の値から決まり、CommonJS なら Node10Node16 なら Node16NodeNext なら NodeNextPreserve なら Bundler、それ以外(ESNext など)では Classic になります。Classicnode_modules を見にいかない非常に古いルールなので、"module": "ESNext" を選んだときは moduleResolutionBundler などを明示的に書く必要があります。

lib で読み込む型定義を決める

lib は、TypeScript が最初から知っている組み込み API の型定義ファイル(lib.*.d.ts)のうち、どれを読み込むかを指定します。ArrayPromise のメソッド、documentwindow といったブラウザ API の型は、すべてこの型定義から来ています。

lib を書かなかった場合の既定値は、target から自動的に決まります。

targetlib の既定値
ES5DOM, ES5, ScriptHost
ES2015DOM, ES2015, DOM.Iterable, ScriptHost
ES2022DOM, ES2022, DOM.Iterable, ScriptHost
ESNextDOM, ESNext, DOM.Iterable, ScriptHost

ここで重要なのは、lib を自分で書くと既定値は追加ではなく完全な置き換えになることです。"lib": ["ES2022"] と書いた時点で DOM は読み込まれなくなり、documentwindowfetch の型が消えます。Node.js 用のプロジェクトではこれが望ましい挙動ですが、ブラウザ向けのコードで同じことをすると大量の型エラーが出ます。

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    // 明示すると既定値(DOM を含む)は完全に置き換わる
    "lib": ["ES2022", "DOM", "DOM.Iterable"]
  }
}

指定できる値は、ECMAScript の年号ごとのものと、実行環境ごとのものに分かれます。よく使うのは次のあたりです。

読み込まれる型定義
ES2022ES2022 までの組み込み API。Array.prototype.atObject.hasOwnErrorcause などが含まれる
ESNextその時点の TypeScript が知っている最新の API。TypeScript を上げると内容が変わる
DOMdocumentwindowHTMLElementfetch などブラウザの API
DOM.IterableNodeListFormData などの DOM のコレクションを for...of やスプレッド構文で扱えるようにする宣言
WebWorkerWeb Worker 内で使える API。DOM の代わりに指定する
ES2022.StringES2022 で追加された String の API だけ(String.prototype.at)。年号全体ではなく機能単位で足したいときに使う
ScriptHostWindows Script Host 用の型。既定値に含まれるが、明示的に足す必要はほぼない

ES2022.String のような細かい単位が用意されているのは、「実行環境がまだ ES2022 の全機能を満たしていないが、この API だけはポリフィルを入れて使っている」という状況に対応するためです。"lib": ["ES2021", "ES2022.String", "DOM"] のように書けば、必要な分だけ型を足せます。

なお、Node.js 固有の processBuffer__dirnamelib には含まれません。これらは npm の @types/node パッケージが提供する型なので、lib をいくら調整しても出てきません。混同しやすいポイントです。

target を下げても新しい API は生えない

ここが3つの設定でもっとも誤解されやすい部分です。target が変換してくれるのは文法だけで、API(メソッドや関数)は変換されません。

アロー関数やクラスは「書き方」なので、TypeScript が古い書き方に置き換えられます。一方、Array.prototype.atObject.hasOwnString.prototype.replaceAll は、実行環境そのものが持っているメソッドです。存在しない環境で呼べば TypeError になるだけで、コンパイラが代わりの実装を用意してくれることはありません。

src/last.ts
export function last(items: string[]): string | undefined {
  // 文法ではなくメソッド。target を下げても変換されず、そのまま出力される
  return items.at(-1);
}

このコードは "target": "ES5" でも items.at(-1) のまま出力されます。at を持たない古いブラウザで動かせば、そこで実行時エラーになります。逆にコンパイル側から見ると、at の型が lib に入っていなければ型エラーになります。つまり、この1つのメソッドを使うにあたって考えることが2つあるわけです。

1つは型の話で、libES2022(または ES2022.Array)が含まれていれば型チェックは通ります。もう1つは実行時の話で、実際にそのメソッドが存在するかどうかは実行環境のバージョン次第です。存在しない環境をサポートするなら core-js のようなポリフィルを読み込む必要があります。lib を上げる操作は「TypeScript に、この API はあるものとして扱ってくれと伝える」だけで、ポリフィルの追加は何もしてくれません。ここを取り違えると、型エラーは消えたのに本番で落ちる、という状況になります。

環境ごとの現実的な設定例

3つの役割が分かれば、あとは「どこで動かすか」から逆算して決められます。代表的な3パターンを見ていきます。

Node.js 向け(tsc でビルドして node で実行する)

Node.js の場合、対象バージョンが決まっているので target は高めにできます。moduleNodeNext にすると、package.jsontype とファイル拡張子から Node.js と同じルールで CommonJS / ES モジュールを判定してくれるので、出力形式の食い違いが起きにくくなります。lib からは DOM を外し、代わりに @types/node を入れます。

tsconfig.json(Node.js 向け)
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    // DOM を含めないので document や window は型エラーになる
    "lib": ["ES2022"],
    // process や Buffer の型は @types/node から来る
    "types": ["node"],
    "outDir": "dist",
    "strict": true
  },
  "include": ["src"]
}

ブラウザ向け(Vite などのバンドラを使う)

バンドラを使う構成では、実際に古い文法へ変換したりファイルをまとめたりするのはバンドラの仕事です。そのため moduleESNext にしてモジュール構文をそのまま渡し、noEmittrue にして tsc は型チェック専用にします。lib には DOMDOM.Iterable を必ず入れます。

tsconfig.json(ブラウザ向け・バンドラ併用)
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    // module: ESNext のとき既定は Classic なので必ず明示する
    "moduleResolution": "Bundler",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    // 出力はバンドラに任せ、tsc は型チェックだけ行う
    "noEmit": true,
    "strict": true
  },
  "include": ["src"]
}

この場合、実際にブラウザへ届く JavaScript の文法バージョンを決めているのはバンドラ側の設定(Vite なら build.target)です。tsconfig の target は、あくまで tsc が出力するとしたらどうなるか、および lib の既定値や useDefineForClassFields の既定値に影響する設定になります。両者の値をなるべく揃えておくと混乱が減ります。

Next.js の場合

Next.js は create-next-apptsconfig.json を生成し、起動時に不足している設定を自動で補います。生成される値は次のような組み合わせです。

tsconfig.json(Next.js の生成例・抜粋)
{
  "compilerOptions": {
    "target": "ES2017",
    "lib": ["dom", "dom.iterable", "esnext"],
    "module": "esnext",
    "moduleResolution": "bundler",
    "jsx": "preserve",
    "noEmit": true,
    "strict": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "isolatedModules": true,
    "resolveJsonModule": true
  }
}

libesnext が入っているのがポイントで、これによって新しい組み込み API の型が使えます。値の大文字・小文字は区別されないので、公式の生成物に合わせて小文字で書かれていても意味は同じです。noEmit: true なので、コードを実際に変換して配信するのは Next.js 側であり、tsc は型チェックだけを担当します。特別な理由がなければ、この生成された値をそのまま使うのが無難です。

エラーが出るときの見分け方

この3つの設定に関係するエラーは、メッセージから「どれの問題なのか」を判断できます。よく出るものを見ていきます。

Property ‘at’ does not exist on type ‘string[]’

メソッドが存在しないという型エラーは、ほぼ lib の問題です。Array.prototype.at は ES2022 で追加された API なので、libES2021 以下だと型定義に入っていません。lib を書いていないなら targetES2022 以上にすれば既定値が上がって解決しますが、lib を明示している場合は target をいくら上げても変わりません。lib の配列そのものを ES2022 に上げるか、ES2022.Array を追加してください。

そのうえで、実行環境にそのメソッドが本当にあるかを確認します。型が通ることと実行時に動くことは別問題です。古い環境をサポートするなら、ポリフィルを入れるか、items[items.length - 1] のように昔からある書き方に置き換えます。

Cannot find name ‘document’ / Cannot find name ‘window’

これは libDOM が含まれていないときのエラーです。多くの場合、"lib": ["ES2022"] のように lib を明示したことで既定値の DOM が外れています。ブラウザで動くコードなら DOMDOM.Iterable を配列に足してください。Web Worker のコードなら WebWorker を指定します。

似たエラーでも Cannot find name 'process'Cannot find name '__dirname'lib の話ではありません。npm i -D @types/node で型パッケージを入れる必要があります。types オプションで読み込む型パッケージを絞っている場合は、そこに "node" が入っているかも確認してください。

Cannot use import statement outside a module / require is not defined

コンパイルは通るのに実行時に落ちるこの2つは、module で出力した形式と実行環境が期待する形式が食い違っているサインです。Cannot use import statement outside a module は、import のまま出力された JavaScript を CommonJS として読み込もうとしたときに出ます。逆に require is not defined は、require() に変換された JavaScript を ES モジュールとして読み込んだときに出ます。

Node.js の場合、ファイルがどちらとして扱われるかは package.jsontype"module" なら ES モジュール、無指定または "commonjs" なら CommonJS)と拡張子(.mjs は ES モジュール、.cjs は CommonJS)で決まります。moduleNodeNext にしておくと、TypeScript がこの判定を Node.js と同じルールで行い、食い違いを型チェックの段階で検出してくれます。手動で合わせるなら、"type": "module" のプロジェクトでは "module": "ESNext"、そうでなければ "module": "CommonJS" と対応させてください。

トップレベル await が使えないと言われる

関数の外で await を書くと、「Top-level ‘await’ expressions are only allowed when the ‘module’ option is set to …」という長いエラーが出ることがあります。これは moduletarget の両方に条件があるためです。moduleES2022 / ESNext / Node16 以降 / Preserve などのいずれかであり、かつ targetES2017 以上である必要があります。moduleCommonJS のままだと、この機能は使えません。3つの設定が互いに影響し合う、分かりやすい例です。

まとめ

target は出力する JavaScript の文法バージョン、module は出力するモジュール形式、lib はコンパイル時に読み込む組み込み API の型定義です。target を上げると libmodule の既定値も変わるため、意図しない変化を避けたいなら3つとも明示的に書くのが安全です。lib を自分で書いた時点で既定値は完全に置き換わるので、ブラウザ向けなら DOMDOM.Iterable を忘れずに含めてください。

そしてもっとも重要なのは、target が変換するのは文法だけで、Array.prototype.at のような API は変換もポリフィルもされないという点です。API を使いたいときに必要なのは lib による型定義の追加と、実行環境に応じたポリフィルの2つで、これらは別々の作業です。エラーメッセージが「型が存在しない」なら lib、「実行時に落ちる」なら module の形式かポリフィルの不足、と切り分けていくと原因にたどり着きやすくなります。

参考ページ