WebpackとBabelによるReact/TypeScript開発環境の構築方法

WebpackとBabelを使ってReact + TypeScriptの開発環境をゼロから構築し、実際にビルドを実行して検証します。Babelの型消去とtscの型検査の違い、開発/本番ビルドの実測サイズ比較、ソースマップによるスタックトレース復元まで解説。

本記事では、webpackBabel を用いて、ReactとTypeScriptを使ったモダンなWebアプリケーション開発環境をゼロから構築する手順を解説します。単に設定ファイルを並べるだけでなく、なぜこの3つのツールを組み合わせる必要があるのかBabelの型変換に潜む落とし穴、そして実際にビルドした際の生の出力(ビルドログ、バンドルサイズ、エラーメッセージ)を示しながら、動作原理を検証していきます。

Webpackとは

Webpack は、JavaScriptアプリケーションのためのモジュールバンドラーです。複数のJavaScriptファイルやその他のアセット(CSS、画像など)を依存関係に基づいて解析し、ブラウザで実行可能な単一または複数のファイルにまとめます。

  • 主な機能:
    • モジュール解決: importrequire などの構文を解釈し、依存関係を解決します。
    • バンドル: 複数のファイルを一つにまとめます。
    • トランスパイル: Babelなどのローダーと連携し、新しいJavaScript構文を古いブラウザでも動作するように変換します。
    • 最適化: コードの圧縮、不要なコードの削除など、パフォーマンス向上のための最適化を行います。

Babelとは

Babel は、JavaScriptのトランスコンパイラーです。主に、最新のJavaScript(ES2015+)で書かれたコードを、古いブラウザや実行環境でも動作する互換性のあるJavaScript(ES5など)に変換するために使用されます。

  • 主な機能:
    • 新しい構文の変換: async/await, アロー関数、クラス構文などを変換します。
    • JSXの変換: ReactのJSX構文を通常のJavaScriptに変換します。
    • TypeScriptの変換: TypeScriptコードをJavaScriptに変換します。

最後の「TypeScriptの変換」には重要な注意点があります。次の節で詳しく説明します。

なぜ3つのツールを併用するのか

Webpack・Babel・TypeScript(tsc)はいずれも「コードを変換する」という点で役割が重なって見えますが、実際には担当領域がまったく異なり、互いを代替できません。

ツール担当領域得意なことできないこと
Babel構文変換(syntax transform)JSX/最新構文/TypeScript構文を1ファイル単位で高速に変換型の整合性チェック(型情報を一切見ない)
tsc(TypeScript)型検査(type checking)・型に基づく変換プロジェクト全体を横断した型の整合性チェック高速な単一ファイル変換(低速)
Webpackモジュール解決・バンドル・パイプライン統括依存グラフの構築、ローダー/プラグインの実行順序管理構文変換や型チェックそのもの(担当はローダーに委譲)

ポイントは、Babelの @babel/preset-typescriptTypeScriptの構文を「消す」だけで、型を「検査」しない ということです。Babelは isolatedModules(1ファイルずつ独立に変換する方式)を前提に設計されているため、そもそも他ファイルの型情報を参照できず、型チェックが原理的に不可能です。これは省略された実装ではなく、Babelのアーキテクチャ上の制約です。

一方 tsc はプロジェクト全体の型グラフを構築してから検査するため、正確な型チェックができますが、その分1ファイルだけを素早く変換するのには向いていません。

そのため実務では次のような分業が一般的です。

  1. Babel(babel-loader が高速にJSX/TSの構文を変換し、Webpackがバンドルする(型チェックはスキップ)
  2. tsc --noEmit をビルドとは別プロセス・別コマンドとして実行し、型エラーだけを検出する(CI・pre-commit・エディタのLanguage Server機能など)
  3. あるいは fork-ts-checker-webpack-plugin のようなプラグインで、型チェックをバックグラウンドの別プロセスとして走らせ、ビルド速度を落とさずに型エラーを警告として表示する

この分業を理解せずに babel-loader だけを使っていると、「ビルドが通ったから型は安全」という誤った安心感を持ってしまいます。これが実務で頻発するハマりどころです。後の節で、この現象を実際に手を動かして再現します。

環境構築(実際に検証)

以下の手順は、下記の環境で実際にコマンドを実行して検証しました。

Node.js: v26.5.0
npm: 11.17.0
@babel/core: 8.0.1
@babel/preset-env: 8.0.2
@babel/preset-react: 8.0.1
@babel/preset-typescript: 8.0.1
babel-loader: 10.1.1
webpack: 5.108.4
webpack-cli: 7.2.1
typescript: 7.0.2
react / react-dom: 19.2.7

執筆時点でのメジャーバージョン(Babel 8, TypeScript 7, React 19)は本記事初出時(2022年)より進んでいますが、「Babelが構文だけを変換し型検査をしない」「tscが型検査を担う」というアーキテクチャ自体は変わっていません。この点は次のセクションで実際に確認します。

プロジェクトディレクトリを作成し、npmで初期化します。

mkdir react_test
cd react_test

# プロジェクトの初期化
npm init -y

# Babel関連モジュール
npm install -D @babel/core          # Babel本体
npm install -D @babel/preset-env    # 最新のJS構文をターゲット環境に合わせて変換
npm install -D @babel/preset-react  # ReactのJSXを変換
npm install -D @babel/preset-typescript # TypeScriptを変換

# Webpack関連モジュール
npm install -D webpack webpack-cli babel-loader # webpack本体とCLI、BabelをWebpackで扱うためのローダー

# ReactとTypeScript関連モジュール
npm install react react-dom         # React本体とDOM操作ライブラリ
npm install -D typescript @types/react @types/react-dom # TypeScript本体とReact/ReactDOMの型定義

# tsconfig.json の生成
npx tsc --init

ファイル構成

最終的なファイル構成は以下のようになります。

.
├── dist/
│   ├── index.html
│   └── bundle.js
├── node_modules/
├── package-lock.json
├── package.json
├── src/
│   ├── index.html
│   └── index.tsx
├── tsconfig.json
└── webpack.config.js

各種設定ファイル

src/index.html

ReactアプリケーションのエントリポイントとなるHTMLファイルです。div 要素にReactコンポーネントがマウントされます。

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>React App</title>
  </head>
  <body>
    <div id="app"></div>
    <!-- WebpackによってバンドルされたJavaScriptがここに自動的に挿入される -->
  </body>
</html>

src/index.tsx

Reactアプリケーションのメインエントリファイルです。TypeScriptとJSXで記述されます。本記事の検証では、後述の型チェックデモがわかりやすいように、独自の props を持つ子コンポーネントを用意しました。

import React from "react";
import { createRoot } from "react-dom/client";

interface GreetingProps {
  name: string;
}

const Greeting = ({ name }: GreetingProps) => {
  return <h1>Hello, {name}!</h1>;
};

const App = () => {
  return <Greeting name="React with TypeScript" />;
};

const container = document.getElementById("app");
if (container) {
  const root = createRoot(container);
  root.render(<App />);
}

.babelrc

Babelの設定ファイルです。どのプリセット(プラグインのセット)を使用してコードを変換するかを定義します。

{
  "presets": [
    "@babel/preset-env",
    "@babel/preset-react",
    "@babel/preset-typescript"
  ]
}

tsconfig.json

TypeScriptのコンパイラ設定ファイルです。noEmit: true にしているのは、JSへの出力はBabel/Webpackが担当し、tsc は型検査専用として使うという分業を明示するためです。

{
  "compilerOptions": {
    "target": "es2020",
    "module": "esnext",
    "jsx": "react-jsx",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "lib": ["dom", "dom.iterable", "esnext"]
  },
  "include": ["src"]
}

isolatedModules: true は、「各ファイルを他のファイルの型情報なしに単独でコンパイル可能であること」をtsc自身に強制させるオプションです。これはBabelが型情報を見ずに1ファイルずつ変換することと平仄を合わせるための設定で、isolatedModules に違反するコード(例えば const enum の使用)はBabel環境では正しく変換できないため、tsc の段階でエラーとして検出されます。

webpack.config.js

Webpackのバンドル設定ファイルです。開発時と本番時でソースマップの種類を切り替えている点に注目してください(後述のエッジケースで検証します)。

const path = require("path");

module.exports = (env, argv) => ({
  entry: "./src/index.tsx",
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "bundle.js",
    clean: true, // ビルド前にdistディレクトリをクリーンアップ
  },
  resolve: {
    extensions: [".ts", ".tsx", ".js", ".jsx"], // インポート時に解決する拡張子
  },
  module: {
    rules: [
      {
        test: /\.(ts|tsx)$/, // .tsまたは.tsxファイルに適用
        exclude: /node_modules/, // node_modulesは除外
        use: {
          loader: "babel-loader", // Babelを使って変換(型チェックはしない)
        },
      },
    ],
  },
  // mode に応じてソースマップの種類を変える
  devtool: argv.mode === "production" ? "source-map" : "eval-source-map",
});

package.jsonscripts には次のように開発ビルドと本番ビルドのコマンドを登録します。

{
  "scripts": {
    "build:dev": "webpack --mode development",
    "build:prod": "webpack --mode production"
  }
}

図解: ビルドパイプライン全体像

3つのツールがどう連携するかを図にまとめました。Babelによる構文変換とtscによる型検査は並行する別系統のパイプラインであり、tscの型エラーはWebpackのビルドを止めない、という点が本記事で最も重要な図です。

Webpack + Babel + TypeScript のビルドパイプライン図。ソースファイルがBabel(構文変換)とtsc –noEmit(型検査)の2系統に分岐し、Babelの出力のみがWebpackでバンドルされてbundle.jsになる。tscの型エラーはビルドを止めないことを示す

実際にビルドしてみる

上記の設定ファイルを揃えた状態で、実際に npx webpack --mode development を実行した結果です(バンドルサイズや所要時間は実測値、加工していません)。

$ npx webpack --mode development

asset bundle.js 2.89 MiB [emitted] (name: main)
runtime modules 1.46 KiB 5 modules
modules by path ./node_modules/ 1.1 MiB
  modules by path ./node_modules/react/ 58.3 KiB
    modules by path ./node_modules/react/*.js 404 bytes 2 modules
    modules by path ./node_modules/react/cjs/*.js 57.9 KiB 2 modules
  modules by path ./node_modules/react-dom/ 1.04 MiB
    modules by path ./node_modules/react-dom/*.js 2.67 KiB 2 modules
    modules by path ./node_modules/react-dom/cjs/*.js 1.03 MiB 2 modules
  modules by path ./node_modules/scheduler/ 12 KiB
    ./node_modules/scheduler/index.js 194 bytes [built] [code generated]
    ./node_modules/scheduler/cjs/scheduler.development.js 11.9 KiB [built] [code generated]
./src/index.tsx 553 bytes [built] [code generated]
webpack 5.108.4 compiled successfully in 436 ms

bundle.js は 2.89 MiB(実ファイルサイズ 3,029,177 バイト)になりました。React本体だけでなく、開発用の警告メッセージを含む react-dom/cjs/react-dom-client.development.js 相当のコードがそのまま含まれているためです。

エッジケース1: 開発ビルドと本番ビルドの違い(実測比較)

同じソースコードを --mode production でビルドすると、Terserによる圧縮・不要コードの削除・process.env.NODE_ENV の置換(開発用分岐の削除)が行われます。

$ npx webpack --mode production

asset bundle.js 185 KiB [emitted] [minimized] (name: main) 2 related assets
modules by path ./node_modules/react/ 17.6 KiB
  modules by path ./node_modules/react/*.js 404 bytes 2 modules
  modules by path ./node_modules/react/cjs/*.js 17.2 KiB 2 modules
modules by path ./node_modules/react-dom/ 533 KiB
  modules by path ./node_modules/react-dom/*.js 2.67 KiB 2 modules
  modules by path ./node_modules/react-dom/cjs/*.js 530 KiB
    ./node_modules/react-dom/cjs/react-dom-client.production.js 523 KiB [built] [code generated]
    ./node_modules/react-dom/cjs/react-dom.production.js 6.5 KiB [built] [code generated]
modules by path ./node_modules/scheduler/ 10.1 KiB
  ./node_modules/scheduler/index.js 194 bytes [built] [code generated]
  ./node_modules/scheduler/cjs/scheduler.production.js 9.94 KiB [built] [code generated]
./src/index.tsx 553 bytes [built] [code generated]
webpack 5.108.4 compiled successfully in 1210 ms

生成された dist/bundle.js の実サイズと、それをさらに gzip -c dist/bundle.js | wc -c で圧縮したサイズを比較すると次のようになります。

ビルドサイズ(実測)
開発ビルド(無圧縮)2,958 KiB (3,029,177 バイト)
本番ビルド(Terser圧縮)185.0 KiB (189,435 バイト)
本番ビルド + gzip58.2 KiB (59,605 バイト)

開発ビルド・本番ビルド(圧縮)・本番ビルド+gzip の3段階でのbundle.js実測サイズ比較の対数スケール棒グラフ。2,958 KiB → 185 KiB → 58 KiBと段階的に縮小する

開発ビルドから本番ビルド(圧縮)で 約93.7% サイズが縮小し、そこからさらにgzip転送で 約68.5% 縮小します(開発ビルドからの通算では約98.0%減)。この差は主に、(1) React本体が開発版から本番版のモジュールに切り替わり警告コードが消えること、(2) Terserによる変数名の短縮・空白除去・デッドコード除去、(3) gzipによるテキスト圧縮、の3つの要因が重なった結果です。本番デプロイでは必ず --mode production でビルドすることが、この実測差からも裏付けられます。

Babelは型を「消すだけ」で「検査しない」ことを実際に確かめる

ここが本記事の核心です。src/index.tsxApp コンポーネントに、Greetingname prop(型は string)へ意図的に number を渡すバグを仕込みます。

const App = () => {
  // バグ: `name` は `string` 型なのに、number を渡している
  return <Greeting name={42} />;
};

1. babel-loader だけを使うWebpackビルド → 成功してしまう

$ npx webpack --mode production

asset bundle.js 185 KiB [emitted] [minimized] (name: main) 2 related assets
modules by path ./node_modules/react/ 17.6 KiB
  modules by path ./node_modules/react/*.js 404 bytes 2 modules
  modules by path ./node_modules/react/cjs/*.js 17.2 KiB 2 modules
modules by path ./node_modules/react-dom/ 533 KiB
  modules by path ./node_modules/react-dom/*.js 2.67 KiB 2 modules
  modules by path ./node_modules/react-dom/cjs/*.js 530 KiB
    ./node_modules/react-dom/cjs/react-dom-client.production.js 523 KiB [built] [code generated]
    ./node_modules/react-dom/cjs/react-dom.production.js 6.5 KiB [built] [code generated]
modules by path ./node_modules/scheduler/ 10.1 KiB
  ./node_modules/scheduler/index.js 194 bytes [built] [code generated]
  ./node_modules/scheduler/cjs/scheduler.production.js 9.94 KiB [built] [code generated]
./src/index.tsx 598 bytes [built] [code generated]
webpack 5.108.4 compiled successfully in 1228 ms

$ echo "exit code: $?"
exit code: 0

型エラーがあるにもかかわらず、ビルドは警告すら出さずに正常終了します(終了コード0)。babel-loadername={42} の型注釈を単に読み捨てて構文だけを変換するため、そもそも型不整合を検知する仕組みを持っていません。このバンドルはそのまま本番環境にデプロイされ得ます。

2. tsc --noEmit を実行 → 実際にエラーを検出する

$ npx tsc --noEmit

src/index.tsx(14,20): error TS2322: Type 'number' is not assignable to type 'string'.

$ echo "exit code: $?"
exit code: 1

同じコードに対して tsc --noEmit を実行すると、TS2322 エラーが実際に報告され、終了コードも 1(失敗)になります。ちなみにバグを修正した状態(元の name="React with TypeScript")に戻して同じコマンドを実行すると、次のように何も出力されず終了コード 0 で正常終了することも確認済みです。

$ npx tsc --noEmit
$ echo "exit code: $?"
exit code: 0

この2つの実行結果の対比から、次のことが実証できます。

  • Webpack + babel-loader だけの構成では、型エラーがあってもビルドが通り、そのまま本番に出荷されてしまう
  • 型安全性を保証するには、tsc --noEmit をCIやpre-commitフック、あるいは fork-ts-checker-webpack-plugin として別途組み込む必要がある

「TypeScriptを使っているから型安全」という思い込みは、ビルド構成次第では成立しません。tsc --noEmit の実行を省略しない、という運用ルールをチームで徹底することが重要です。

エッジケース2: ソースマップで圧縮後のスタックトレースを元コードに戻す

本番ビルドではコードが1行に圧縮されるため、実行時にエラーが発生してもスタックトレースは意味不明な行番号を指すだけになります。ここでは実際にエラーを投げるコードをビルドし、ソースマップの有無でスタックトレースがどう変わるかを検証します。

検証用の小さなモジュールを用意しました。

// srcmap-demo.ts
function calculateTotal(price: number, quantity: number): number {
  if (quantity < 0) {
    throw new Error("quantity must not be negative");
  }
  return price * quantity;
}

function checkout(price: number, quantity: number): number {
  return calculateTotal(price, quantity);
}

checkout(1000, -1);

devtool: "source-map" で本番ビルドすると、bundle.js は次のように1行へ圧縮されます(.map ファイルが別途生成されます)。

// dist-srcmap/bundle.js の中身(実際の出力そのまま)
!(function () {
  throw new Error("quantity must not be negative");
})();
//# sourceMappingURL=bundle.js.map

このファイルをNode.jsで実行すると、まずソースマップを使わない場合のスタックトレースはこうなります(パスは読みやすさのため短縮しています)。

$ node dist-srcmap/bundle.js

dist-srcmap/bundle.js:1
!function(){throw new Error("quantity must not be negative")}();
            ^

Error: quantity must not be negative
    at dist-srcmap/bundle.js:1:19
    at Object.<anonymous> (dist-srcmap/bundle.js:1:62)
    at Module._compile (node:internal/modules/cjs/loader:1934:14)
    ...

bundle.js:1:19 としか分からず、元々どの関数のどの行で例外が発生したのかは全く読み取れません。次に、Node.js組み込みの --enable-source-maps フラグを付けて実行します(ブラウザの開発者ツールが .map ファイルを自動的に読み込んで行うのと同じ処理です)。

$ node --enable-source-maps dist-srcmap/bundle.js

webpack://react_test/src/srcmap-demo.ts:6
    throw new Error("quantity must not be negative");
    ^

Error: quantity must not be negative
    at calculateTotal (webpack://react_test/src/srcmap-demo.ts:6:11)
    at Object.<anonymous> (webpack://react_test/src/srcmap-demo.ts:12:10)
    at Module._compile (node:internal/modules/cjs/loader:1934:14)
    ...

ソースマップを使うと、圧縮されたコードの1箇所だったスタックトレースが、関数名(calculateTotal、呼び出し元のcheckout相当)・元のファイル名・元の行番号まで正確に復元されました。srcmap-demo.ts の6行目(throw new Error(...))と12行目(calculateTotal の呼び出し)という、実際のソースコード上の位置と完全に一致しています。

ブラウザで本番ビルドのエラーを調査する際も仕組みは同じです。開発者ツールの「Enable JavaScript source maps」設定が有効であれば、圧縮された bundle.js のスタックトレースを開いたときに、自動的に元の .tsx/.ts ファイルの該当行が表示されます。Sentryなどのエラートラッキングサービスも同様に .map ファイルをアップロードしておくことで、本番環境で発生した例外を元のソースコード上の位置として表示します。

なお、webpack.config.js で開発ビルドに eval-source-map、本番ビルドに source-map を使い分けているのは、eval-source-map の方がビルド速度・再ビルド速度に優れる一方でファイルサイズが大きくなるためです。本番の source-map は独立した .map ファイルとして出力されるため、通常のユーザーには配信されず(別途配信設定をしない限り)、デバッグ時にのみ参照されます。

最近のツールチェーン事情:Vite・esbuild・SWCについて

本記事で解説したWebpack + Babel + TypeScriptの構成は現在も広く使われていますが、2026年時点では新規プロジェクトの起点として Vite を勧める声が主流になっています。React公式ドキュメントも、非推奨となったCreate React Appに代わるスタート地点としてViteを案内しています。

  • Vite: 開発時はesbuildによる高速なトランスパイル、本番ビルドは実績のあるRollupを使う構成。プラグインエコシステムが大きく、開発サーバーの起動が非常に高速(数百ミリ秒程度)です。
  • esbuild: Go言語で書かれた超高速なバンドラー/トランスパイラー。ただしBabel同様、TypeScriptの型は「消すだけ」で検査しないため、型安全性の担保には別途 tsc --noEmit が必要な点は本記事の議論と同じです。
  • SWC: Rust製の高速なトランスパイラーで、Babelの代替として使われることが多く、Next.jsのデフォルトトランスパイラーにも採用されています。

いずれのツールを使っても、「高速な構文変換」と「型検査」を分離するという設計思想自体は共通しています。WebpackのModule Federationのような高度な機能が必要な場合や、既存の複雑なWebpack設定を持つレガシープロジェクトを保守する場合は本記事の構成が引き続き有効ですが、新規にプロジェクトを立ち上げる場合はViteの採用を検討する価値があります。本記事はWebpack/Babelの動作原理そのものの理解を目的としているため、構成の刷新は行いませんが、上記の選択肢があることは踏まえておくとよいでしょう。

まとめ

  • Babel・tsc・Webpackはそれぞれ「構文変換」「型検査」「バンドル統括」という異なる役割を持ち、互いを代替できない
  • @babel/preset-typescript は型注釈を消すだけで型チェックは一切行わない。これは実測でも確認済みで、型エラーを含むコードでも babel-loader 経由のWebpackビルドは正常終了する(終了コード0)
  • 型安全性を担保するには tsc --noEmit を独立したステップとして必ず実行する必要がある(実測でも TS2322 エラーを正しく検出し、終了コード1で失敗する)
  • 開発ビルドと本番ビルドでは実測で約38倍(2,958 KiB → 185 KiB → gzipで58.2 KiB)のサイズ差が生じる。本番デプロイでは必ず --mode production を使う
  • ソースマップは圧縮後のスタックトレースを元のファイル・行番号・関数名に正確に復元する。本番ビルドでも .map ファイルを生成し、デバッグ・エラートラッキングに活用できる
  • 2026年現在、新規プロジェクトではViteが主流の選択肢になりつつあるが、「高速変換とは別に型検査が必要」という設計思想は共通している

関連ツール