tsx と Node.js Type Stripping の違い

tsx は TypeScript コードを事前トランスパイルすることなく、直接 Node.js で実行するためのツール。

ところで最近の Node.js には Type Stripping という機能が入った。これを使うと、tsx なしで TypeScript コードを事前トランスパイルせずに実行できる。

両者の違い

一見すると両者は機能的に同じものかのように思うけど、実は結構違いがある。

import specifier の指定方法が異なる

最も大きな違いは、「import specifier」の指定方法。import specifier というのは、以下の部分のこと。

import { add } './math';


const { sleep } = await import('./util');

tsx は import specifier の様々な指定方法に対応しているが、Node.js Type Stripping はかなり限られている。math.ts というモジュールを参照する場合を例にすると…

指定方法 tsx Node.js Type Stripping
'./math' OK NG
'./math.ts' OK OK
'./math.js' OK NG

tsx は bundler がサポートしているような指定方法を同じようにサポートしてて、多くの人が慣れ親しんだ挙動になってる。一方 Node.js Type Stripping は .ts の指定が必須で、クセが強い。

何故このような仕様になってるかというと、1つは実行時のオーバーヘッドを減らすため。というのも、Node.js の ESM には import speficier の拡張子を明示しなければならないという制約がある。

A file extension must be provided when using the import keyword to resolve relative or absolute specifiers. Directory indexes (e.g. ‘./startup/index.js’) must also be fully specified.
https://nodejs.org/api/esm.html#mandatory-file-extensions

拡張子が明示されていれば、CJS or ESM をすぐに判定できる。しかし省略されてると、推測するためのオーバーヘッドが掛かる。それを嫌って、拡張子の明示が強制されている。Type Stripping でも同じで、拡張子が明示されていれば CJS or ESM をすぐ判定できる。それに JavaScript or TypeScript どちらとして処理すれば良いかも、すぐ判定できる。そのために Type Stripping で拡張子の明示を必須としたい、という動機があるらしい。

他にも色々理由があるらしい。拡張子の省略を許可すると require() に破壊的変更を加えることになってしまうから避けたいとか、math.tsmath.js が共存してる時どっちを読むのか曖昧で良くないとか。以下の issue に書いてある。

以下の関連する issue にも様々な理由が書かれていそうだった。けどコメントが多すぎて僕には追いきれなかった…。

tsx は JSX 対応してるが、Node.js Type Stripping は非対応

Node.js Type Stripping は型注釈の削除だけをやる軽量な実装になっており、JSX がサポートされてない。

Node.js Type Stripping では TypeScript 固有の機能に非対応

Enum, experimentalDecorators, namespaces などには対応してない。Node.js Type Stripping は型注釈の削除だけをやるので、こういう JavaScript にない機能は一切サポートしない。

Node.js Type Stripping は tsconfig.json の paths に非対応

tsx は対応してるけど、Node.js Type Stripping は非対応。

Node.js Type Stripping…というか Node.js 自体が Subpath patterns import に対応してるので、それを使うと import alias っぽいことはできる。

どっちを使えば良いの?

せっかく Node.js に組み込まれてる機能があるのだから、Type Stripping が使えるならそうしたほうが良いと思う。しかし、それができないものもある。

バックエンドサーバー

Node.js で実行されているものなので、Node.js Type Stripping を使ったら良いと思う。

npm package

場合によるが、原則として tsx も Node.js Type Stripping も使うべきではない。というのも、npm にはトランスパイル済みのコードをアップロードするべきだから。Node.js も Type Stripping のおかげで直接 .ts を実行できるようにはなったが、今のところ npm package の .ts は Type Stripping の対象外としている。

To discourage package authors from publishing packages written in TypeScript, Node.js will by default refuse to handle TypeScript files inside folders under a node_modules path.
https://nodejs.org/docs/latest/api/typescript.html

tsx や Node.js Type Stripping で開発をして、そのままトランスパイルせずに npm に公開する…ということをしたところで、ユーザの手元で動かない。そのため npm package の開発では tsx や Node.js Type Stripping を使わずに、tsc で事前ビルドするほうが良いと思う。

スクリプトファイル

バッチファイルとか one-time script とかそういうの。基本的に Node.js で実行するものなので、Node.js Type Stripping が使えるならそうしたら良いと思う。

しかし Next.js を使っているプロジェクトで、スクリプトファイルと Next.js で一部モジュールを共有してる、とかだと話が変わってくる。例えば、以下のようなコードがあるとする。

import { PrismaClient, type User } from '@prisma/client';
import { getDatabaseURL } from '@/lib/database';

export const prisma = new PrismaClient({
  datasourceUrl: getDatabaseURL(),
});


export async function findUserById(id: string): Promise<User> {
  return prisma.user.findUnique({ where: id });
}

このモジュールは Next.js のコードから使われてて、かつスクリプトファイルからも使われているとする。

import { findUserById } from '@/lib/prisma';
console.log(await findUserById('123'));

何の変哲も無いスクリプトファイルに見えるけど、node scripts/ユーザ調査.ts するとコケる。

まず import { findUserById } from '@/lib/prisma'; が良くない。拡張子を省略をせず、tsconfig.jsonpaths も使わず、以下のように書くべき。

// scripts/ユーザ調査.ts
-import { findUserById } from '@/lib/database';
+import { findUserById } from '../lib/prisma.ts';
 console.log(await findUserById('123'));

依存先の lib/prisma.ts で拡張子の省略が行われるのも良くない。以下のように書くべき。

// lib/prisma.ts
 import { PrismaClient, type User } from '@prisma/client';
-import { getDatabaseURL } from '@/lib/database';
+import { getDatabaseURL } from './database.ts';
 // ...

このように bundler で実行してる部分とモジュールの共用をしようと思うと、コードベースの書き換えが必要になる。正直面倒だし、ややこしい。

拡張子は明示する、paths やめる、と書き換えていっても良いとは思うけど、それくらいならスクリプトファイルを tsx で実行したほうが楽な気はする。

CLI ツールの設定ファイル

eslint.config.ts, prettier.config.ts, vitest.config.ts など。これは場合による。そもそもこれらのファイルはユーザが実行するというよりは、CLI ツールが内部で読み取って、実行するタイプのもの。CLI ツール側で Node.js Type Stripping を使ったり、tsx を使ったりして実行している。TypeScript の実行に何を使っているかは、CLI ツールによって異なる。

例えば eslint.config.ts は jiti、もしくは Node.js Type Stripping で実行される。

prettier.config.ts は Node.js Type Stripping で実行される。

vitest.config.ts は特殊で、Vite で bundle して .js に変換した後、Node.js で実行される。

vitest.config.ts は拡張子の省略はできるけど、prettier.config.ts はできない。eslint.config.ts は jiti で動かしてるなら省略できるけど、Node.js Type Stripping ならできない。難しいね…

まあ通常これらのファイルから他のモジュールを import することは稀なので、あんまり困らないとは思うけど。

おまけ: エディタによる import 文の補完を制御する

エディタ…というか TypeScript の Language Server には import 文を補完する機能がある。その補完で拡張子を省略するのか、明記するのかを制御するオプションが実はある。VS Code なら以下のオプションで制御できる。

  • "typescript.preferences.importModuleSpecifierEnding"
  • "javascript.preferences.importModuleSpecifierEnding"

あとは "@/lib/math" と補完するのか、"./math" と補完するのかを制御するオプションもある。

  • "typescript.preferences.importModuleSpecifier"
  • "javascript.preferences.importModuleSpecifier"

こういうのを上手く使うと、プロジェクト内で import 文の補完方法を上手く制御できるはず。




Source link

関連記事

コメント

この記事へのコメントはありません。