blog.euxn.me

tsconfig の path alias 解決に tsconfig-pathsregister を node で使う方法と TS 依存の分離方法

2019-12-25 Wed.

この記事は TypeScript アドベントカレンダー 2019 の 24 日目です。

はじめに

Webpack 等でビルドせずに node で実行する際に tsconfig の path alias が解決されなくて困る方も多いと思います。 一方 ts-node じゃなくても tsconfig-paths/register で path alias が解決できることは意外と知られておらず、実は $ node -r tsconfig-paths/register dist/main.js で解決します。 しかし、 Production で動く node に TypeScript 由来の何かに依存しているのは怖いということもあるので、 tsconfig-paths の中身を読んだので何をしているかを説明します。

サンプルプロジェクト構成

以下の構成で実行します。サンプルリポジトリは以下になります。

https://github.com/euxn23/how-tsconfig-paths-work-sample

1$ tree .
2.
3├── package.json
4├── src
5├── main.ts
6└── path
7└── to
8└── nested
9└── lib
10└── hello.ts
11├── tsconfig.json
12└── yarn.lock
tsconfig.json
1{
2 "compilerOptions": {
3 "target": "es2018",
4 "module": "commonjs",
5 "declaration": true,
6 "declarationMap": true,
7 "sourceMap": true,
8 "outDir": "./dist",
9 "rootDir": "./src",
10 "strict": true,
11 "noUnusedLocals": true,
12 "noUnusedParameters": true,
13 "noImplicitReturns": true,
14 "noFallthroughCasesInSwitch": true,
15 "moduleResolution": "node",
16 "baseUrl": "./",
17 "typeRoots": ["./node_modules/@types"],
18 "types": ["node"],
19 "allowSyntheticDefaultImports": true,
20 "esModuleInterop": true,
21 "experimentalDecorators": true,
22 "emitDecoratorMetadata": true,
23 "resolveJsonModule": true,
24 "paths": {
25 "@lib/*": ["src/path/to/nested/lib/*", "dist/path/to/nested/lib/*"]
26 }
27 },
28 "include": [
29 "src/**/*.ts*"
30 ],
31 "exclude": [
32 "node_modules",
33 "dist"
34 ]
35}
main.ts
1import { sayHello } from '@lib/hello'
2
3sayHello();
hello.ts
1export function sayHello() {
2 console.log('Hello tsconfig-paths demo')
3}

ts-node / node で実行する

ts-node で実行する場合でも tsconfig-paths が必要なので、以下のように実行します。

1$ yarn ts-node -r tsconfig-paths/register src/main.ts

node で実行する場合も同様です。

1$ yarn tsc
2$ node -r tsconfig-paths/register dist/main.js

ここでポイントとなるのは、 tsconfig の baseUrl と paths の設定です。 tsconfig-paths/register の path 解決は baseUrl を元に解決されます。 そのため、 baseUrl が ./src の場合、この config をそのまま使って上記のように node で実行すると、 src/path/to/nested/lib/hello.ts を見に行ってしまい、 .js でないので Error: Cannot find module '@lib/hello' となってしまいます。

そのために、 path の設定に srcdist の両方を設定しています。(なお、 bash の正規表現 {src,dist} は使えないようでした。)

tsconfig-paths/register は ts に依存しないのか

簡単な動作確認として、typescript, ts-node 等を devDependencies に、 tsconfig-paths/register のみ dependencies に定義し、動作を確認します。 yarn install --production するため、事前にビルドをしておきます。

package.json
1 "devDependencies": {
2 "@types/node": "^13.1.0",
3 "ts-node": "^8.5.4",
4 "typescript": "^3.7.4"
5 },
6 "dependencies": {
7 "tsconfig-paths": "^3.9.0"
8 }
1$ rm -rf dist && yarn tsc
2$ rm -rf node_modules
3$ yarn install --production

依存ツリーを確認し、 typescript や ts-node が含まれていないことを確認します。

1$ yarn list --production
2yarn list v1.21.1
3├─ @types/json5@0.0.29
4├─ json5@1.0.1
5└─ minimist@^1.2.0
6├─ minimist@1.2.0
7├─ strip-bom@3.0.0
8└─ tsconfig-paths@3.9.0
9 ├─ @types/json5@^0.0.29
10 ├─ json5@^1.0.1
11 ├─ minimist@^1.2.0
12 └─ strip-bom@^3.0.0

この状態で node で実行します。

1$ node -r tsconfig-paths/register dist/main.js
2Hello tsconfig-paths demo

動作することから、実行時に typescript に依存していないだろうことが分かります。 念の為以下で確認します。

tsconfig-paths/register が何をしているのか実装を確認する

該当関数は以下になります。

https://github.com/dividab/tsconfig-paths/blob/master/src/register.ts#L52

src/register.ts
1export function register(explicitParams: ExplicitParams): () => void {
2 const configLoaderResult = configLoader({
3 cwd: options.cwd,
4 explicitParams
5 });
6
7 if (configLoaderResult.resultType === "failed") {
8 console.warn(
9 `${configLoaderResult.message}. tsconfig-paths will be skipped`
10 );
11
12 return noOp;
13 }
14
15 const matchPath = createMatchPath(
16 configLoaderResult.absoluteBaseUrl,
17 configLoaderResult.paths,
18 configLoaderResult.mainFields,
19 configLoaderResult.addMatchAll
20 );
21
22 // Patch node's module loading
23 // tslint:disable-next-line:no-require-imports variable-name
24 const Module = require("module");
25 const originalResolveFilename = Module._resolveFilename;
26 const coreModules = getCoreModules(Module.builtinModules);
27 // tslint:disable-next-line:no-any
28 Module._resolveFilename = function(request: string, _parent: any): string {
29 const isCoreModule = coreModules.hasOwnProperty(request);
30 if (!isCoreModule) {
31 const found = matchPath(request);
32 if (found) {
33 const modifiedArguments = [found, ...[].slice.call(arguments, 1)]; // Passes all arguments. Even those that is not specified above.
34 // tslint:disable-next-line:no-invalid-this
35 return originalResolveFilename.apply(this, modifiedArguments);
36 }
37 }
38 // tslint:disable-next-line:no-invalid-this
39 return originalResolveFilename.apply(this, arguments);
40 };
41
42 return () => {
43 // Return node's module loading to original state.
44 Module._resolveFilename = originalResolveFilename;
45 };
46}

この実装を読んでわかる通り、 TypeScript 文脈のものは何も出てきておらず、 node の module を拡張しているのみのようです。

また、上記の通り typescript / ts-node は dependencies にも peerDependencies にも入っていません。

実行時コンテキストを tsconfig.json に依存させたくない

上記で実行時に typescript への依存がないことは分かりましたが、 tsconfig.json への依存さえも無くしたいケースもあるかと思います。 単純に node で実行するのに tsconfig.json の変更に影響されることを嫌う場合や、 Firebase Functions などで tsconfig.json へのファイル参照を行いたくない場合などです。 これの解決のため、2 つの方法を紹介します。

  1. tsconfig-paths の register にオプション引数を渡す

README の Bootstraping with explicit params にも紹介がありますが、 明示的にオプションを渡して以下のように実行できます。

tsconfig-paths-bootstrap.js
1const tsConfigPaths = require("tsconfig-paths");
2
3const baseUrl = "./";
4const paths = {
5 "@lib/*": ["dist/path/to/nested/lib/*"]
6}
7
8tsConfigPaths.register({
9 baseUrl,
10 paths
11});
1$ node -r ./tsconfig-paths-bootstrap.js main.js
2Hello tsconfig-paths demo
  1. module-alias を使う

tsconfig-paths/register と似たことをしてくれる module-alias というライブラリがあります。 こちらはそもそもプロジェクトに TypeScript を導入していなくても使えるものです。

package.json
1 "_moduleAliases": {
2 "@lib/hello":
3 "dist/path/to/nested/lib/hello.js"
4 }
1$ node -r module-alias/register dist/main.js

ただしこちらは alias に Array / ワイルドカードが指定できないという制約があります。 どうしても tsconfig-paths を使いたくない、という場合は、必要に応じて検討してください。

おわりに

Production で動く node に TypeScript 由来の何かに依存しているのは怖いという思いを解消するため、 tsconfig-paths/register の挙動や実装を確認し、回避策を紹介しました。 これで安心して node アプリケーションでも path alias を使用できると思います。