Node.jsでTypeScriptのコードを実行できるようになるかも

2024 / 07 / 08

Edit
module: add --experimental-strip-types by marco-ippolito · Pull Request #53725 · nodejs/node It is possible to execute TypeScript files by setting the experimental flag --experimental-strip-typ...

💁‍♀️ まだマージされてない点に注意してください

--experimental-strip-typesというフラグを実行時に付けることにより、Node.jsでTypeScriptのコードを実行できるようになるPRが出てきました。

背景

TC39でも型注釈の話題(議事録を読むとブラウザとの兼ね合いもあり道のりは長そう)が存在するほどJSのコードにおいて、型は当たり前となっています。 Node.jsと同じ立ち位置であるDenoやBunはTypeScriptをネイティブサポートしていますが、Node.jsはサポートしていません。 なので普段、TypeScriptを利用するときにはts-nodetsxなどのエグゼキューター、esbuild-registerのようなレジスターを利用するかと思います。 時代的に必須のものとなっている以上、Node.jsにもネイティブで入れていくべきでは?というのが背景となります。

どのように動作するか?

Node.jsの内部では、@swc/wasm-typescriptを動かし、 TypeScriptの型を落とします。swc/coreesbuildも検討したとのことですが、RustやGolangをツールチェーンに追加しないといけなく、 Wasmとなりました。Node.jsはC++がベースなのでWasmは良い使い方だと感じます。結果的にDenoもRustベースでswcを利用しているので、似たようなアプローチになりました。

ちなみにtscは、8.5MBもあるので、利用しなかったとのことです。

案外、その内部実装はシンプルで、以下はCJSへの変換例です。ESMはもう少し複雑です。 内部のmodule loaderに.ts.cts拡張子ならトランスパイルをするという処理が追加されています。

lib/internal/modules/cjs/loader.js
Module._extensions[".ts"] = function (module, filename) {
  const content = getMaybeCachedSource(module, filename);
  const { parseTScode } = require("internal/modules/typescript/typescript");
  const code = parseTScode(content);
  const pkg = packageJsonReader.getNearestParentPackageJSON(filename);
  // Function require shouldn't be used in ES modules.
  if (pkg?.data.type === "module") {
    if (getOptionValue("--experimental-require-module")) {
      module._compile(content, filename, "module");
      return;
    }
    const parent = module[kModuleParent];
    const parentPath = parent?.filename;
    const packageJsonPath = path.resolve(pkg.path, "package.json");
    const usesEsm = containsModuleSyntax(code, filename);
    const err = new ERR_REQUIRE_ESM(
      filename,
      usesEsm,
      parentPath,
      packageJsonPath,
    );
    throw err;
  }
  module._compile(code, filename, "commonjs");
};

Module._extensions[".cts"] = function (module, filename) {
  const content = getMaybeCachedSource(module, filename);
  const { parseTScode } = require("internal/modules/typescript/typescript");
  const code = parseTScode(content);
  module._compile(code, filename, "commonjs");
};
lib/internal/modules/typescript/typescript.js
"use strict";

const { stringify } = require("internal/modules/helpers");
const { transformSync } = require("internal/deps/swc/wasm");

function parseTScode(source) {
  const result = transformSync(stringify(source), {
    mode: "strip-only",
  });
  return result;
}

module.exports = {
  parseTScode,
};

サポートされている拡張子も、.tsをはじめ.cts, .mtsが一部サポートされているので、変換後のJSはCJS、ESMのどちらにも対応しています。

注意点

まだ初期PRなので、今後改善されていくはずですが、現段階だと以下のような制限があります。

Support typescript with --experimental-strip-types · Issue #208 · nodejs/loaders Add this issue to keep track of the work for supporting typescript out of the box, in the PR you can...

型検査ができない

tscを使ってないので、型が正しいかを確認することはできません。 このアプローチはNext.jsとかと同様であくまでもTypeScriptからJavaScriptへ変換するだけなので、tscは引き続き利用するかと思います。

// index.ts
const foo: string = 1;

console.log(foo);
$ ./node --experimental-strip-types index.ts
1

Node.jsもこの流れになると、またこれを機会にstcみたいなすごいチャレンジャーが出てくるかもしれません。

拡張子は省略できない

最初のリリース時は、拡張子を見てTSかどうかの判断を行うため、拡張子がない場合はエラーとなるだろうと思います。 理想的には、拡張子は書かなくても動くことなので、nodejs/loaders側でこれから議論がなされていきます。

// index.ts

// Error: Must use .ts extension when using TypeScript files
import { getDate } from "./getDate";

console.log(getDate());
$ ./node --experimental-strip-types index.ts

node:internal/modules/run_main:121
    triggerUncaughtException(
    ^

Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/Users/hiroppy/node/getDate' imported from /Users/hiroppy/node/index.ts
Did you mean to import "./getDate.ts"?
    at finalizeResolution (node:internal/modules/esm/resolve:260:11)
    at moduleResolve (node:internal/modules/esm/resolve:921:10)
    at defaultResolve (node:internal/modules/esm/resolve:1120:11)
    at ModuleLoader.defaultResolve (node:internal/modules/esm/loader:557:12)
    at ModuleLoader.resolve (node:internal/modules/esm/loader:526:25)
    at ModuleLoader.getModuleJob (node:internal/modules/esm/loader:249:38)
    at ModuleJob._link (node:internal/modules/esm/module_job:126:49) {
  code: 'ERR_MODULE_NOT_FOUND',
  url: 'file:///Users/hiroppy/node/getDate'
}

Node.js v23.0.0-pre

importするファイルに拡張子をつけると動きます。

// index.ts

import { getDate } from "./getDate.ts";

console.log(getDate());
2024-07-07T13:22:29.663Z

TypeScript固有の機能は使えない

Enum, experimentalDecorators, namespacesなどのTypeScript固有の機能はサポートされていません。

// index.ts

enum Fruits {
  Apple,
  Orange,
  Pineapple,
}
$ ./node --experimental-strip-types index.ts

node:internal/modules/run_main:121
    triggerUncaughtException(
    ^
  x TypeScript enum is not supported in strip-only mode
   ,-[1:1]
 1 | ,-> enum Fruits {
 2 | |     Apple,
 3 | |     Orange,
 4 | |     Pineapple
 5 | `-> };
   `----

(Use `node --trace-uncaught ...` to show where the exception was thrown)

Node.js v23.0.0-pre

その他

  • REPL, --print, --check, inspectでは利用できない
  • ソースマップがサポートされていない

まとめ

  • まだマージされるかわからないが、Node.jsで直接TypeScriptのコードを実行できるようになるかもしれない
  • 利用できる拡張子は、.ts, .cts, .mts
  • 型検査は行えないので、別途tscを利用する必要がある
  • 現段階では、拡張子を書かないとTypeScriptとして判断しない
  • TypeScript固有の機能はサポートされていない

ファイル量によってはパフォーマンスの問題が出てしまうかもしれないですが、 将来的にnode_modules内のコードがTypeScriptで書かれている場合でもユーザーランドでトランスパイルせずに実行できるかもと思うと楽しみですね。 すでにissueとしてはこちらも上がっているので、今後も注目したい機能です。