8
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ts-nodeを捨ててNode.jsネイティブTSに移行したら環境構築が5分で終わった話

8
Posted at

この記事の対象読者

  • Node.js + TypeScriptで開発している方
  • ts-node / tsx / tsc のビルドステップに疲れている方
  • 「Node.jsがTypeScriptをそのまま実行できるらしい」と聞いて気になっている方

この記事で得られること

  • Node.jsネイティブTypeScript実行(Type Stripping)の仕組みと使い方がわかる
  • ts-nodeからの移行手順が具体的にわかる
  • enumが使えない問題の実用的な回避策がわかる
  • **「結局どこまで使えるのか」**の判断材料が手に入る

この記事で扱わないこと

  • Deno / Bunとの詳細な比較(別記事で扱います)
  • フロントエンドのビルドパイプライン(Vite / webpack等)への影響
  • TypeScript 5.9の新機能の詳細

1. ts-nodeとの「お別れ」のきっかけ

「またts-nodeのバージョン不整合か...」

Node.jsとTypeScriptで開発していると、誰しも一度はこの呪文を唱えたことがあるんじゃないだろうか。

# こういうの、見覚えない?
$ npx ts-node src/index.ts
TypeError: Unknown file extension ".ts"

tsconfig.jsonをいじり、ts-nodeセクションを追加し、esModuleInteroptrueにして、moduleResolutionを変えて... 気がつくと30分が溶けている。書きたいのはビジネスロジックであって、ビルド設定ではない。

そんな折、2025年7月31日にリリースされたNode.js v22.18.0 LTSで、ついにType Strippingがデフォルト有効になった。つまり、こうなった:

$ node index.ts   # これだけ。嘘じゃない。

ts-nodeもtscも、tsxすらも要らない世界が、もう来ていた。

ここまでで、「おっ」と思った方もいるだろう。次は、この記事で使う用語を整理しておこう。


2. 前提知識の確認

本題に入る前に、この記事で登場する用語を確認しておく。

2.1 Type Strippingとは

Type Stripping(型剥がし)とは、TypeScriptの型注釈をホワイトスペース(空白)に置き換えて除去し、残ったJavaScriptコードをそのまま実行する手法のこと。料理で例えるなら、食材(JavaScript)の包装フィルム(型注釈)をペリッと剥がして、そのまま鍋に入れるイメージだ。

// 元のTypeScript
function greet(name: string): string {
  return `Hello, ${name}!`;
}

// Type Stripping後(Node.jsが実行するもの)
function greet(name        )         {
  return `Hello, ${name}!`;
}

ホワイトスペースで置き換えるため、行番号がそのまま保持される。ソースマップが不要になるという大きなメリットがある。

2.2 Erasable Syntax(消去可能構文)とは

TypeScriptの構文のうち、削除してもランタイムの動作に影響しないものを「Erasable Syntax」と呼ぶ。型注釈、インターフェース、typeキーワードなどがこれにあたる。

逆に、enumnamespace、クラスのパラメータプロパティは削除するとコードの意味が変わってしまうため、「Non-erasable Syntax」となる。

2.3 Amaro / SWC

Node.jsの内部でType Strippingを実行しているライブラリ。AmaroはNode.js公式のラッパーで、その中身はSWC(Rustで書かれた超高速トランスパイラ)のWebAssembly版。tscの数十倍速い。

これらの用語が押さえられたら、Type Strippingが生まれた背景を見ていこう。


3. Node.jsネイティブTypeScriptが生まれた背景

3.1 「ビルドステップ」という10年来の呪い

2012年にMicrosoftがTypeScriptをリリースして以来、開発者は常に**「書くコード」と「実行されるコード」の間にあるギャップ**と戦ってきた。

時代 手段 痛み
2012〜 tsc手動実行 毎回コンパイルが必要
2016〜 ts-node登場 オンザフライ変換だが遅い & 設定地獄
2019〜 esbuild / swc 高速だが外部ツール依存
2020〜 Deno / Bun TS対応だがNode.jsエコシステムと互換性の壁
2024〜 Node.js Type Stripping ゼロ設定、ビルトイン

3.2 DenoとBunが証明した「未来」

DenoとBunは「ランタイムがTypeScriptを直接理解できる」ことを世界に証明した。しかし、Node.jsには数百万のnpmパッケージAWSやGCP等のクラウドプラットフォームでのファーストクラスサポートがある。

Node.js本体がTypeScriptを直接実行できるなら、移行コストゼロでこの恩恵を受けられる。それがType Strippingの動機だった。

3.3 バージョンごとの進化

バージョン リリース日 内容
v22.6.0 2024年8月 --experimental-strip-typesフラグ追加
v22.7.0 2024年8月 --experimental-transform-types追加(enum等に対応)
v23.6.0 2025年1月 Type Strippingがデフォルト有効化(Current版)
v22.18.0 2025年7月 LTS版でもデフォルト有効化
v24.3.0 2025年 Experimental警告が消去
v25.2.0 2025年11月 Type Strippingがstable(安定版)に昇格

背景がわかったところで、基本的な仕組みを見ていこう。


4. 基本概念と仕組み

4.1 Type Strippingの動作原理

Node.jsがType Strippingで行っていることは、極めてシンプルだ:

  1. .tsファイルを読み込む
  2. Amaro(SWCベース)で型注釈をホワイトスペースに置換
  3. 残ったJavaScriptコードをV8エンジンに渡して実行

型チェックは一切行わない。 string型の引数にnumberを渡してもNode.jsは何も言わずに実行する。型チェックは引き続きtsc --noEmitやIDEに任せる設計だ。

4.2 Erasable vs Non-erasable

分類 構文 Type Strippingで使えるか
Erasable 型注釈(: string等) 使える
Erasable interface / type 使える
Erasable ジェネリクス(<T> 使える
Erasable asキャスト 使える
Erasable typeインポート 使える
Non-erasable enum 使えない
Non-erasable namespace(実行コードあり) 使えない
Non-erasable クラスのパラメータプロパティ 使えない
Non-erasable JSX (.tsx) 使えない

Non-erasable構文を使うと SyntaxError: TypeScript enum is not supported in strip-only mode が出る。これに遭遇して白目になった人は、私だけではないはずだ。

4.3 ts-nodeとの決定的な違い

比較軸 ts-node Node.js Type Stripping
起動速度 約120ms 約45ms
インストール npm install ts-node typescript 不要(ビルトイン)
設定ファイル tsconfig.json必須 不要(型チェック用に推奨はされる)
enum対応 対応 --experimental-transform-typesで対応
型チェック オプションで実行 行わない
ソースマップ 生成する 不要(行番号が保持される)
メモリ使用量 大(トランスパイラをメモリにロード)

基本概念が理解できたところで、実際にコードを書いて動かしてみよう。


5. 実践:実際に使ってみよう

5.1 環境構築

# Node.jsのバージョン確認(v22.18.0以上が必要)
node -v
# v22.18.0以上なら準備完了。本当にこれだけ。

v22.18.0未満を使っている場合は、--experimental-strip-typesフラグを手動で付ける必要がある:
node --experimental-strip-types index.ts

5.2 最小構成で試す

// hello.ts — これだけのファイルを作る
function greet(name: string): string {
  return `Hello, ${name}!`;
}

interface User {
  name: string;
  age: number;
}

const user: User = { name: "Hiroki", age: 30 };
console.log(greet(user.name));
console.log(`Age: ${user.age}`);
$ node hello.ts
Hello, Hiroki!
Age: 30

以上。ts-nodeのインストールもtsconfig.jsonの作成も不要。

5.3 プロジェクト構成のベストプラクティス

実際のプロジェクトでは、以下の構成を推奨する:

my-project/
├── package.json
├── tsconfig.json        # 型チェック用(Node.jsは読まない)
├── src/
│   ├── index.ts
│   ├── utils.ts
│   └── types.ts
└── tests/
    └── index.test.ts

package.json

{
  "name": "my-project",
  "type": "module",
  "scripts": {
    "start": "node src/index.ts",
    "dev": "node --watch src/index.ts",
    "typecheck": "tsc --noEmit",
    "test": "node --test tests/"
  },
  "devDependencies": {
    "typescript": "^5.8.0"
  }
}

"type": "module" を設定すると、.tsファイルがESモジュールとして扱われる。設定しない場合はCommonJSになる。

5.4 環境別の設定ファイル

以下の3種類のtsconfig.jsonを用意した。用途に応じて選択してほしい。

開発環境用(tsconfig.json)— 型チェック専用

{
  "compilerOptions": {
    "target": "esnext",
    "module": "nodenext",
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "rewriteRelativeImportExtensions": true,
    "verbatimModuleSyntax": true,
    "erasableSyntaxOnly": true,
    "strict": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*.ts"],
  "exclude": ["node_modules"]
}

本番ビルド用(tsconfig.build.json)— JSファイル出力が必要な場合

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "noEmit": false,
    "outDir": "./dist",
    "allowImportingTsExtensions": false,
    "rewriteRelativeImportExtensions": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["src/**/*.ts"],
  "exclude": ["node_modules", "tests"]
}

CI/テスト用(tsconfig.test.json)

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "noEmit": true
  },
  "include": ["src/**/*.ts", "tests/**/*.ts"]
}

5.5 importの書き方(ここが最大のハマりポイント)

ts-nodeからの移行で最も躓くのがimportパスだ。

// ❌ ts-node時代の書き方(動かない)
import { helper } from './utils';
import { MyType } from './types';

// ✅ Node.jsネイティブTS対応の書き方
import { helper } from './utils.ts';         // 拡張子.tsが必須
import type { MyType } from './types.ts';    // 型はtype importが必須

import type を使わずに型をインポートすると、Node.jsはそれを値のインポートとして扱い、ランタイムエラーになる。TypeScript 5.0以降の verbatimModuleSyntax を有効にすれば、エディタが警告してくれる。

5.6 実行結果

上記の構成で実際にプロジェクトを動かしてみよう:

// src/types.ts
export interface Config {
  port: number;
  host: string;
  debug: boolean;
}

export type Environment = 'development' | 'production' | 'test';
// src/utils.ts
import type { Config, Environment } from './types.ts';

export function loadConfig(env: Environment): Config {
  const configs: Record<Environment, Config> = {
    development: { port: 3000, host: 'localhost', debug: true },
    production:  { port: 8080, host: '0.0.0.0', debug: false },
    test:        { port: 3001, host: 'localhost', debug: true },
  };
  return configs[env];
}
// src/index.ts
import { loadConfig } from './utils.ts';
import type { Environment } from './types.ts';

const env: Environment = (process.env.NODE_ENV as Environment) ?? 'development';
const config = loadConfig(env);

console.log(`Server starting on ${config.host}:${config.port}`);
console.log(`Debug mode: ${config.debug}`);
$ node src/index.ts
Server starting on localhost:3000
Debug mode: true

$ NODE_ENV=production node src/index.ts
Server starting on 0.0.0.0:8080
Debug mode: false

5.7 よくあるエラーと対処法

エラー 原因 対処法
SyntaxError: TypeScript enum is not supported in strip-only mode enumを使っている as constオブジェクトで代替する(後述)
ERR_MODULE_NOT_FOUND importパスに.ts拡張子がない import ... from './file.ts'に修正
SyntaxError: Unexpected token ':' Node.jsのバージョンが古い v22.18.0以上にアップデート
TypeError: ... is not a function import typeを使うべき場所でimportを使っている 型のみのインポートにはimport typeを使う
ExperimentalWarning: Type Stripping is an experimental feature v24.3.0未満の警告 node --disable-warning=ExperimentalWarningで抑制
ERR_UNSUPPORTED_DIR_IMPORT ディレクトリインポートしている ファイルパスを明示的に指定する

5.8 環境診断スクリプト

問題が発生した場合は、以下のスクリプトで環境を診断できる:

// check-env.ts
import { version } from 'process';

function checkEnvironment(): void {
  const issues: string[] = [];

  // Node.jsバージョン確認
  const [major, minor] = version.slice(1).split('.').map(Number);
  if (major < 22 || (major === 22 && minor < 18)) {
    issues.push(`Node.js v22.18.0以上が必要です(現在: ${version})`);
    issues.push('  → nvm install 22 && nvm use 22');
  }

  // package.jsonのtypeフィールド確認
  try {
    const fs = await import('fs');
    const pkg = JSON.parse(fs.readFileSync('package.json', 'utf-8'));
    if (pkg.type !== 'module') {
      issues.push('package.jsonに "type": "module" がありません(ESM推奨)');
    }
  } catch {
    issues.push('package.jsonが見つかりません');
  }

  // TypeScriptバージョン確認
  try {
    const { execSync } = await import('child_process');
    const tsVersion = execSync('npx tsc --version', { encoding: 'utf-8' }).trim();
    console.log(`TypeScript: ${tsVersion}`);
    // 5.8以上を推奨
    const tsMinor = parseInt(tsVersion.split(' ')[1]?.split('.')[1] ?? '0');
    if (tsMinor < 8) {
      issues.push('TypeScript 5.8以上を推奨します(erasableSyntaxOnlyサポート)');
    }
  } catch {
    issues.push('TypeScriptがインストールされていません(型チェック用に推奨)');
  }

  if (issues.length > 0) {
    console.log('❌ 問題が見つかりました:');
    for (const issue of issues) {
      console.log(`  - ${issue}`);
    }
  } else {
    console.log('✅ 環境は正常です — node file.ts で実行できます');
  }
}

checkEnvironment();
$ node check-env.ts
TypeScript: Version 5.9.0
✅ 環境は正常です — node file.ts で実行できます

実装方法がわかったので、次は具体的なユースケースを見ていこう。


6. ユースケース別ガイド

6.1 ユースケース1: CLIスクリプト / ワンショットタスク

想定読者: 日常的にPythonやBashで書いているようなスクリプトをTypeScriptで書きたい人

推奨構成: 単一ファイルで完結。tsconfig.jsonすら不要。

サンプルコード:

// cleanup.ts — 古いログファイルを削除するスクリプト
import { readdir, stat, unlink } from 'fs/promises';
import { join } from 'path';

const LOG_DIR = './logs';
const MAX_AGE_DAYS = 30;

async function cleanupOldLogs(dir: string, maxAgeDays: number): Promise<number> {
  const files = await readdir(dir);
  const now = Date.now();
  const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000;
  let deleted = 0;

  for (const file of files) {
    if (!file.endsWith('.log')) continue;
    const filepath = join(dir, file);
    const fileStat = await stat(filepath);

    if (now - fileStat.mtimeMs > maxAgeMs) {
      await unlink(filepath);
      console.log(`Deleted: ${file}`);
      deleted++;
    }
  }
  return deleted;
}

const count = await cleanupOldLogs(LOG_DIR, MAX_AGE_DAYS);
console.log(`Done. ${count} files deleted.`);
$ node cleanup.ts
Deleted: app-2026-01-15.log
Deleted: app-2026-01-20.log
Done. 2 files deleted.

6.2 ユースケース2: Express APIサーバー(開発環境)

想定読者: ExpressでAPIを開発していて、開発時のビルドステップを省きたい人

推奨構成: node --watchとの組み合わせでホットリロード開発

サンプルコード:

// src/server.ts
import express from 'express';
import type { Request, Response } from 'express';

interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

const app = express();
app.use(express.json());

const todos: Todo[] = [];
let nextId = 1;

app.get('/todos', (_req: Request, res: Response) => {
  res.json(todos);
});

app.post('/todos', (req: Request, res: Response) => {
  const todo: Todo = {
    id: nextId++,
    title: req.body.title,
    completed: false,
  };
  todos.push(todo);
  res.status(201).json(todo);
});

app.listen(3000, () => {
  console.log('Server running on http://localhost:3000');
});
# ホットリロード付きで起動
$ node --watch src/server.ts
Server running on http://localhost:3000

node --watchはNode.js v18.11.0以降で利用可能。nodemonも不要になる。

6.3 ユースケース3: enumの代替パターン(最重要)

想定読者: 既存プロジェクトでenumを多用していて移行に悩んでいる人

推奨構成: as constオブジェクト + 型ユーティリティ

enumが使えないのはNode.jsネイティブTS最大の制約だ。しかし、as constパターンで実用上はほぼ完全に代替できる。

サンプルコード:

// ❌ これは動かない(Non-erasable構文)
// enum Direction {
//   Up = 'UP',
//   Down = 'DOWN',
//   Left = 'LEFT',
//   Right = 'RIGHT',
// }

// ✅ as const パターンで代替
const Direction = {
  Up: 'UP',
  Down: 'DOWN',
  Left: 'LEFT',
  Right: 'RIGHT',
} as const;

// 型を自動生成(enum相当の型安全性を確保)
type Direction = (typeof Direction)[keyof typeof Direction];
// → 'UP' | 'DOWN' | 'LEFT' | 'RIGHT'

function move(direction: Direction): void {
  console.log(`Moving ${direction}`);
}

move(Direction.Up);     // ✅ OK
move('UP');             // ✅ OK(リテラル型として一致)
// move('DIAGONAL');    // ❌ コンパイルエラー(型チェック時)
// ❌ パラメータプロパティも動かない
// class User {
//   constructor(private name: string) {}
// }

// ✅ 通常のプロパティ宣言で代替
class User {
  private name: string;
  constructor(name: string) {
    this.name = name;
  }
}

TypeScript 5.8のerasableSyntaxOnlyオプションを有効にしておくと、Non-erasable構文を使った時点でエディタが警告してくれるので、ランタイムエラーの前に気付ける。

ユースケースを把握できたところで、この先の学習パスを確認しよう。


7. 学習ロードマップ

この記事を読んだ後、次のステップとして以下をおすすめする。

初級者向け(まずはここから)

  1. Node.js公式: Running TypeScript Natively — 公式チュートリアル
  2. 手元の小さなスクリプトを1つ、.tsにリネームしてnode script.tsで動かしてみる
  3. tsconfig.jsonerasableSyntaxOnly: trueを追加して、非対応構文がないかチェック

中級者向け(実践に進む)

  1. 既存プロジェクトのts-nodeをnodeコマンドに段階的に置き換える
  2. enumをas constパターンに移行する
  3. Node.js公式API: TypeScript — 制約事項の詳細を把握
  4. CI/CDパイプラインでtsc --noEmitの型チェックを組み込む

上級者向け(さらに深く)

  1. TC39 Types as Comments Proposal — ブラウザでもType Strippingが実現する未来
  2. Amaro (nodejs/amaro) — Node.js内部のType Strippingエンジン
  3. --experimental-transform-typesフラグで、enum/namespaceの変換動作を検証する

8. まとめ

この記事では、Node.jsネイティブTypeScript実行(Type Stripping)について以下を解説した:

  1. v22.18.0以降、node file.tsだけでTypeScriptが実行できる — ts-node / tsx / tsc のビルドステップは開発時に不要
  2. 仕組みは「型をホワイトスペースに置換するだけ」 — 超高速で、ソースマップも不要
  3. enum / namespace / パラメータプロパティは使えないas constパターンで代替可能
  4. 型チェックはNode.jsの仕事ではないtsc --noEmitをCIに組み込むのがベストプラクティス

私の所感

正直なところ、最初は「enum使えないの? じゃあ無理じゃん」と思った。しかし実際に移行してみると、enumをas constに書き換える作業は機械的で、半日もかからなかった。

それ以上に、開発サーバーの起動が一瞬になる体験のインパクトが大きい。node --watch src/index.tsで、ファイルを保存した瞬間にサーバーが再起動される。ts-node時代の「あの数秒の待ち」がゼロになる感覚は、一度味わうと戻れない。

Node.jsは「TypeScriptのランタイム」ではなく「JavaScriptのランタイム」であり続ける、というスタンスが設計から明確に見える。型チェックという重い仕事はtscに任せ、実行に必要な最小限の処理だけを引き受ける。この割り切りが、結果として最もシンプルで高速な開発体験を生み出している。

2026年、TypeScript開発の「普通」が変わりつつある。まだ試していないなら、今日の5分で体験してみてほしい。


参考文献


この記事が役に立ったら、いいねとストックをお願いします。

X: https://x.com/geneLab_999

8
12
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?