1. ホーム
  2. TypeScript

【TypeScript】tsc コマンドの使い方|–watch・–noEmit で型チェックとコンパイルを実行する

Share

TypeScript を書いていると、コンパイルは Vite や Next.js が裏で勝手にやってくれて、tsc というコマンドを自分で叩く機会は意外と少ないかもしれません。ですが、CI で型エラーを止めたいときや、ライブラリをビルドして .d.ts を配りたいときには、結局この tsc を直接使うことになります。この記事では、tsc の基本的な呼び出し方と、--watch--noEmit といったよく使うオプション、そして「設定したはずの tsconfig.json が効いていない」ときに何を疑えばよいかを整理します。

tsc は TypeScript コンパイラの CLI

tsc は TypeScript Compiler の略で、typescript パッケージに同梱されているコマンドラインツールです。やることは大きく2つで、書いた .ts ファイルの型を検査することと、それを .js ファイルに変換して出力することです。この2つは分離できて、後で説明する --noEmit を付けると検査だけを行います。

インストールはプロジェクトの開発依存として入れるのが基本です。グローバルに入れる方法もありますが、プロジェクトごとに TypeScript のバージョンが違うことは普通にあるので、リポジトリに固定できるローカルインストールをおすすめします。

ターミナル
# プロジェクトに開発依存としてインストールする
npm i -D typescript

# ローカルにインストールした tsc を実行する
npx tsc --version
# Version 5.9.3

npx を付けると、node_modules/.bin にあるコマンドが優先して実行されます。逆に npx を付けずに tsc とだけ打つと、シェルは PATH の中から探すので、グローバルに入っている別バージョンが動いたり、そもそも「command not found: tsc」になったりします。ターミナルから叩くときは npx tscpackage.jsonscripts に書くときは(npm がローカルの .binPATH に足してくれるので)tsc のまま、と覚えておくと迷いません。

引数なしの tsc は tsconfig.json を読む

tsc を引数なしで実行すると、カレントディレクトリの tsconfig.json を探し、そこに書かれた includecompilerOptions に従ってプロジェクト全体をコンパイルします。これが本来の使い方です。

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "strict": true,
    "outDir": "dist"
  },
  "include": ["src"]
}
ターミナル
# tsconfig.json を読んで src 以下をコンパイルし、dist へ出力する
npx tsc

エラーが1つも無ければ何も表示されずに終了し、終了コードは 0 になります。型エラーがあると src/index.ts(3,7): error TS2322: ... のような形式で一覧が出て、終了コードは 2 になります。CI で「エラーがあればジョブを落とす」が自然に成立するのは、この終了コードのおかげです。

ファイル名を直接渡すと tsconfig.json は無視される

tsc のいちばん引っかかりやすい仕様がこれです。tsc src/index.ts のようにファイル名を引数に渡すと、tsconfig.json は読まれません。一部だけ上書きされるのではなく、設定ファイルの存在ごと無かったことになり、コンパイラの既定値でコンパイルされます。

先ほどの tsconfig.json があるプロジェクトで、実際に比べてみます。

src/index.ts
export const f = (x: number) => x ** 2;

class A {
  p = 1;
}
ターミナル
# tsconfig.json どおり。dist/index.js に ES2022 で出力される
npx tsc

# tsconfig.json は無視される。
# outDir も target も効かず、src/index.js が ES5 で出力される
npx tsc src/index.ts

後者で出てくる src/index.js は次のようになります。target の既定値が es5module の既定値が commonjs なので、アロー関数は function に、**Math.pow() に、exportexports への代入に変換されています。

src/index.js(tsconfig.json が無視された結果)
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.f = void 0;
var f = function (x) { return Math.pow(x, 2); };
exports.f = f;
var A = /** @class */ (function () {
    function A() {
        this.p = 1;
    }
    return A;
}());

「1ファイルだけ型チェックしたい」と思って npx tsc src/index.ts と打つと、strict が外れた状態で検査され、しかもソースの隣に .js が散らかる、というのがよくある事故です。単一ファイルを試したいだけなら npx tsc src/index.ts --noEmit --strict のようにオプションも一緒に渡す必要があります。コマンドラインで渡したオプションは、ファイル名を指定した場合でも有効です。

–project(-p)で tsconfig.json の場所を指定する

カレントディレクトリ以外の設定を使いたいときは --project(短縮形 -p)を使います。設定ファイルそのもののパスでも、それを含むディレクトリのパスでも受け付けます。

ターミナル
# ディレクトリを指定する(その中の tsconfig.json が使われる)
npx tsc -p packages/core

# ファイルを直接指定する。ビルド用と型チェック用で設定を分ける定番パターン
npx tsc -p tsconfig.build.json

-p とファイル名を同時に渡すことはできません。npx tsc -p . src/index.ts のように書くと、「Option ‘project’ cannot be mixed with source files on a command line.」(TS5042)で拒否されます。設定ファイルを使うモードとファイルを直接指定するモードは、そもそも排他だということです。

よく使うオプション

tsc のオプションは tsconfig.jsoncompilerOptions に書けるものとほぼ共通していて、キー名の前に -- を付けたものがそのままフラグ名になります。実務でコマンドラインから直接使う頻度が高いのは次のあたりです。

オプション説明
--watch / -w入力ファイルを監視し、変更が検出されるたびに再コンパイルする
--noEmitファイルを出力せず、型チェックだけを行う
--project / -p使用する tsconfig.json(またはそれを含むフォルダ)のパスを指定する
--init推奨設定を書いた tsconfig.json をカレントディレクトリに作成する
--outDir出力する .js などの置き場所となるフォルダを指定する
--target / -t出力する JavaScript の言語バージョンを指定する(既定は es5
--declaration / -d型定義ファイル(.d.ts)も生成する
--noEmitOnError型エラーが1つでもあれば、ファイルを出力しない
--showConfigビルドせず、最終的に適用される設定を JSON で表示する
--build / -bプロジェクト参照を持つ構成を、依存関係の順に必要な分だけビルドする
--prettyエラー出力を色付き・整形表示する(既定は true--pretty false で無効)
--version / -vコンパイラのバージョンを表示する

指定できるオプションの全量は npx tsc --help --all で確認できます。かなりの分量が出るので、目的のものが分かっているときは npx tsc --help --all | grep declaration のように絞り込むと早いです。

コマンドラインの指定は tsconfig.json より優先される

設定ファイルを読むモード(引数にファイル名を渡さない場合)では、コマンドラインで渡したオプションが tsconfig.json の値を上書きします。設定ファイルは残したまま、その場かぎりで挙動を変えたいときに便利です。

ターミナル
# tsconfig.json に outDir があっても、今回だけ出力しない
npx tsc --noEmit

# tsconfig.json の target を無視して、今回だけ esnext で出力する
npx tsc --target esnext

ただし include / exclude / files はコマンドラインオプションではないので、この方法で対象ファイルを変えることはできません。対象を変えたい場合は、tsconfig.jsonextends で継承した別ファイルを用意し、-p で切り替えるのが定石です。

–noEmit で型チェックだけを走らせる

--noEmit は、名前のとおり「出力しない」オプションです。型チェックは通常どおり行われ、結果だけがターミナルに出て、.js.d.ts も1つも書き出されません。

これが重要になるのは、変換をバンドラやフレームワークに任せている構成です。Vite・Next.js・esbuild・SWC といったツールは、TypeScript の型をチェックせずに剥がすだけで JavaScript を出力します。速い代わりに型エラーを見逃すので、型の番人としての役割だけを tsc --noEmit に担当させる、という分業になります。

package.json
{
  "scripts": {
    "typecheck": "tsc --noEmit",
    "typecheck:watch": "tsc --noEmit --watch",
    "build": "vite build",
    "ci": "npm run typecheck && npm run lint && npm test"
  },
  "devDependencies": {
    "typescript": "^5.9.3"
  }
}

npm run typecheck という名前で登録しておくと、CI の設定にも、コミット前のフックにも、そのまま同じコマンドを書けます。GitHub Actions なら「依存をインストールして npm run typecheck を実行する」というステップを足すだけで、型エラーのあるプルリクエストをマージできなくできます。エラーがあれば終了コードが 0 以外になるので、追加の判定は要りません。

コミット前に走らせる場合は、少し性格が変わります。tsc はプロジェクト全体を読み直すため、変更したファイルが1つでも実行時間はプロジェクトの規模に比例します。ESLint や Prettier のように変更ファイルだけを対象にする、という絞り込みが(後述する理由で)できないので、規模が大きいリポジトリではコミット前ではなくプッシュ前のフックに置く、あるいは CI だけに任せる、といった判断が現実的です。

なお、出力はしたいがエラーがあるときは止めたい、という場合は --noEmit ではなく --noEmitOnError を使います。tsc は既定では型エラーがあっても .js を出力してしまう(型を消せば動くコードは作れるため)ので、ビルド成果物に壊れたコードを混ぜたくないなら、このオプションを有効にしておくと安全です。

–watch でファイルの変更を監視する

--watch-w)を付けると、tsc は最初の1回を実行したあとも終了せず、対象ファイルを監視し続けます。ファイルが保存されるたびに再チェックが走り、結果がその場で更新されます。

ターミナル(npx tsc –noEmit –watch の出力)
3:49:44 PM - Starting compilation in watch mode...

src/index.ts(3,7): error TS2322: Type 'number' is not assignable to type 'string'.

3:49:44 PM - Found 1 error. Watching for file changes.

(src/index.ts を修正して保存すると)

3:49:48 PM - File change detected. Starting incremental compilation...

3:49:48 PM - Found 0 errors. Watching for file changes.

2回目以降が「incremental compilation」となっているとおり、監視モードは毎回すべてを作り直すわけではありません。tsc は前回の結果をメモリに保持していて、変更されたファイルと、それに依存しているファイルだけを再チェックします。初回の起動には数秒かかっても、以降の反映が一瞬なのはこのためです。

ひとつ知っておくと便利なのが、既定では再実行のたびに画面がクリアされることです。過去のエラーが流れて消えるのが嫌なときは --preserveWatchOutput を付けると、履歴が残ったまま追記されていきます。ログをファイルに流しているときにも、こちらのほうが読みやすくなります。

エディタの赤線とは見ている範囲が違う

「VS Code が型エラーを教えてくれるのに、なぜ tsc --watch が要るのか」という疑問はもっともです。両者は同じ TypeScript を使っていますが、対象の範囲が違います。エディタが動かしている言語サーバーは、応答速度のために基本的に開いているファイルとその周辺を解析します。一方 tsctsconfig.jsoninclude に含まれるファイルを毎回すべて見ます。

この差が効いてくるのが、型を変更したときです。共通の型定義から id: numberid: string に変えた場合、その型を使っている画面をすべて開いていないかぎり、エディタ上には何も赤線が出ません。tsc --watch を1つ立ち上げておくと、閉じているファイルの分もその場で「Found 12 errors.」と教えてくれます。エディタは「いま書いている場所」を、tsc は「プロジェクト全体」を見ていると考えると、両方動かしておく理由が分かりやすいと思います。

先ほど「変更ファイルだけを対象にする絞り込みができない」と書いたのも同じ理由です。tsc に変更したファイルだけを渡すと、それは「ファイル名を直接渡す」ことになって tsconfig.json が無効になりますし、そもそも影響範囲は変更ファイルの外側に広がるので、部分的なチェックには意味がありません。型チェックはプロジェクト単位で行うもの、と割り切るのが正解です。

設定した tsconfig.json が反映されないとき

strict を有効にしたのにエラーが増えない、outDir を指定したのに出力先が変わらない。こうした「書いたのに効かない」現象は、原因がだいたい3つに絞られます。

ファイル名やパターンを引数に渡している

いちばん多いのが、前述の「ファイル名を渡すと tsconfig.json が無視される」パターンです。package.jsonscripts"typecheck": "tsc --noEmit src/**/*.ts" のように書いてあると、シェルがグロブを展開してファイル名の列になるため、設定ファイルは一切読まれません。strictpathsjsx も効かない状態で検査されるので、通ってしまうエラーが出ます。

対象を絞りたい気持ちは include 側で解決します。スクリプトからはファイル名を消して tsc --noEmit だけにし、どのファイルを見るかは tsconfig.json に書く、という役割分担にしてください。

別のディレクトリの tsconfig.json を読んでいる

tsc が探すのは実行時のカレントディレクトリにある tsconfig.json です。編集していたのがリポジトリのルートの設定で、実行していたのがサブパッケージの中、というモノレポでのすれ違いはよく起こります。npm scripts から実行する場合、カレントディレクトリはその package.json がある場所になるので、ルートから npm run --workspace で呼んだときにどこが基準になるかは意識しておく必要があります。

紛らわしい場合は -p で明示してしまうのが確実です。設定ファイルが見つからず、かつ引数にファイル名も無い場合、tsc はコンパイルを始めずにヘルプを表示して終了コード 1 で終わります。「何も起きずにヘルプだけ出る」ときは、設定ファイルが見つかっていないと考えてください。

意図しないバージョンの tsc が動いている

グローバルに古い TypeScript が入っていて、そちらが動いているケースです。新しいバージョンで追加されたオプション(たとえば moduleResolutionbundlermodulepreserve)を書いていると、古い tsc はその値を知らないため「Argument for ‘–moduleResolution’ option must be…」といったエラーを出します。設定は正しいのに怒られる、という状況になります。

ターミナル
# いま動いている tsc の実体を確認する
which tsc

# プロジェクトにインストールされているバージョンを確認する
npx tsc --version
npm ls typescript

「command not found: tsc」と出る場合も根は同じで、グローバルに入っていないだけです。npm i -g typescript でグローバルに入れる手もありますが、プロジェクトごとのバージョン差でハマる元なので、npx tsc か npm scripts 経由に統一するほうが安全です。

エディタが使う TypeScript も別枠で管理されています。VS Code は既定で同梱版を使うため、ワークスペースのバージョンと食い違うことがあります。コマンドパレットの「TypeScript: Select TypeScript Version」から「Use Workspace Version」を選んでおくと、エディタと tsc の結果がずれにくくなります。

tsc –showConfig で最終的な設定を確認する

原因の切り分けにいちばん効くのが --showConfig です。これを付けるとコンパイルは行われず、実際に適用される設定が JSON で出力されます。extends による継承の解決も、コマンドラインからの上書きも、省略された既定値の補完もすべて済んだ後の姿が見えます。

次は、targetstrictoutDir だけを書いた tsconfig.json に対する出力です(実際にはもう少し多くの項目が並びます)。

ターミナル(npx tsc –showConfig の出力)
{
    "compilerOptions": {
        "target": "es2022",
        "strict": true,
        "outDir": "./dist",
        "module": "es6",
        "moduleResolution": "classic",
        "noImplicitAny": true,
        "strictNullChecks": true,
        "strictFunctionTypes": true,
        "alwaysStrict": true
    },
    "files": [
        "./src/index.ts"
    ],
    "include": [
        "src"
    ]
}

この出力には自分が書いていない項目も並びます。"strict": true と1行書いただけで noImplicitAnystrictNullChecks が展開されて表示されるのは、strict がそれらをまとめて有効にするフラグだからです。また、上の例では module を指定していないため target から es6 が推測され、その結果 moduleResolutionclassic になっています。意図していない値が混ざっていないかを見るのに、ちょうどよい出力です。

もうひとつ有用なのが files の項目です。ここには includeexclude を評価した結果、実際に検査対象となるファイルの一覧が展開されます。「このファイルの型エラーが検出されない」というときは、まずこの一覧に入っているかを確認してください。入っていなければ、原因は型ではなく include の書き方です。

ターミナル
# -p と組み合わせて、特定の設定ファイルの最終形を見る
npx tsc -p tsconfig.build.json --showConfig

# コマンドラインの上書きが効いているかも確認できる
npx tsc --showConfig --target esnext --noEmit

ファイルがなぜ対象に含まれたのか、その理由まで知りたい場合は --explainFiles があります。こちらは各ファイルについて「include で指定された」「別のファイルから import された」といった取り込みの経緯を出力するので、node_modules の型定義が大量に読まれている原因を追うときに役立ちます。

–build(-b)は複数プロジェクトのビルド用

--build-b)を付けたときの tsc は、コンパイラというよりビルドの司令塔として振る舞います。references で互いを参照し合う複数の tsconfig.json(プロジェクト参照)を対象に、依存関係の順序を解決し、出力が最新でないプロジェクトだけをビルドします。モノレポで packages/core をビルドしてから packages/app をビルドする、といった段取りを自前のスクリプトで書かずに済ませられます。

ターミナル
# カレントディレクトリのプロジェクトとその依存をビルドする
npx tsc -b

# 何がビルドされるかだけを確認する(実行はしない)
npx tsc -b --dry

# 最新かどうかを無視して、すべて作り直す
npx tsc -b --force

# 出力を削除する
npx tsc -b --clean

参照される側のプロジェクトには "composite": true が必要で、これを有効にすると declaration も自動的に有効になります。上流のプロジェクトが出力した .d.ts を下流が読む、という仕組みで成り立っているからです。

細かい点ですが、-b を付けると短縮形の意味が変わります。通常のモードでは -d--declaration-v--version ですが、ビルドモードでは -d--dry-v--verbose になります。ビルドモードで使えるフラグは --verbose / --dry / --force / --clean などに限られるので、コンパイラオプションを一緒に渡したい場合は tsconfig.json 側に書く必要があります。

まとめ

tscnpm i -D typescript で入れて npx tsc または npm scripts から呼ぶのが基本形です。引数なしで実行するとカレントディレクトリの tsconfig.json を読み、その設定に従ってプロジェクト全体を型チェックしてコンパイルします。エラーがあれば終了コードが 2 になるので、CI にそのまま組み込めます。

いちばん注意したいのは、tsc src/index.ts のようにファイル名を渡すと tsconfig.json が丸ごと無視され、既定値(targetes5modulecommonjs)でコンパイルされることです。--project-p)で設定ファイルの場所を明示するか、対象は include 側で管理して、コマンドにはファイル名を書かないようにしてください。

バンドラが変換を担当する構成では、tsc --noEmit を型チェック専用のコマンドとして npm scripts に登録しておくと、CI でもローカルでも同じ検査を回せます。開発中は --watch を立ち上げておけば、エディタでは見えない他ファイルへの影響も即座に分かります。そして設定が効いていないと感じたら、推測で書き換える前に npx tsc --showConfig で最終的な設定と対象ファイルの一覧を確認するのが近道です。

参考ページ