Open13

調査:良いDXをライブラリユーザーに提供するために、TypeScriptライブラリのtsconfig設定はどうあるべきか?

suinsuin

このスクラップでは、TypeScriptでライブラリを開発し、それをNPMを通じて提供する際、ライブラリユーザーにより良い開発体験(DX)を提供するには、tsconfigの設定はどうあるべきかを調査するものです。

このスクラップが役立つかも知れない人

  • TypeScriptでライブラリを開発する人

アプリ開発者にはたぶん役に立ちません

良いDXとは?

良いDXは次のように定義する。

TypeScriptソースコードの閲覧可能

  • GOOD: IDEでライブラリのシンボル(関数名など)へジャンプしたときに、その実装がTypeScriptで読める
  • BAD: 型定義ファイルにジャンプするので、実装を知ろうと思ったら、対応するバージョンのソースコードをGitHubで調べないといけない
  • SUPER BAD: 型定義ファイルすら無く、コンパイル後のJavaScriptにジャンプする

ブラウザでソースコードが閲覧可能

  • GOOD: 例外などのスタックトレースから、TypeScriptソースコードにジャンプできる
  • BAD: 例外などのスタックトレースから、コンパイル後のJavaScriptにジャンプしてしまう

二次コンパイルの対応

  • GOOD: Babelやwebpackでライブラリを二次コンパイルしたとき、ソースマップが引き継がれる
  • BAD: 二次コンパイルすると、ソースマップが引き継がれない

Node.jsでのスタックトレースのみやすさ

node -r source-map-support/register compiled.js
  • GOOD: source-map-supportを使って実行したとき、スタックトレースに現れるのがライブラリのソースコードになる
suinsuin

01: 残念なライブラリ

まずは理想とはほど遠いライブラリ。

残念なライブラリの特徴

  • 型定義ファイルはあるものの、
  • TypeScriptのソースコードが同梱されていない
.
├── node_modules
│   └── your-package ... 残念なライブラリ
│       ├── index.d.ts … 型定義ファイル
│       ├── index.js … コンパイル後のJS
│       └── package.json
├── package.json
└── main.js ... 残念なライブラリのユーザー

ライブラリの型定義ファイル

your-package/index.d.ts
declare const _default: () => Promise<never>;
export default _default;

ライブラリの実装。コンパイル後のコードなので、可読性はそんなに良くない。

your-package/index.js
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
    function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
    return new (P || (P = Promise))(function (resolve, reject) {
        function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
        function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
        function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
        step((generator = generator.apply(thisArg, _arguments || [])).next());
    });
};
export default () => __awaiter(void 0, void 0, void 0, function* () {
    throw new Error("this is dummy error");
});

ライブラリユーザーのコード

main.js
import func from "your-package";

func();

Node.jsで実行したときの様子。

❯❯❯ node main.js
file:///app/node_modules/your-package/index.js:11
    throw new Error("this is dummy error");
          ^

Error: this is dummy error
    at file:///app/node_modules/your-package/index.js:11:11
    at Generator.next (<anonymous>)
    at file:///app/node_modules/your-package/index.js:7:71
    at new Promise (<anonymous>)
    at __awaiter (file:///app/node_modules/your-package/index.js:3:12)
    at default (file:///app/node_modules/your-package/index.js:10:22)
    at file:///app/main.js:3:1
    at ModuleJob.run (node:internal/modules/esm/module_job:175:25)
    at async Loader.import (node:internal/modules/esm/loader:178:24)
    at async Object.loadESM (node:internal/process/esm_loader:68:5)

WebStormのコードジャンプは型定義ファイルに行く。

VS Codeのコードジャンプも型定義ファイルに行く。

完全なコード

suinsuin

02: TypeScriptのソースコードも同梱したライブラリ

上に加えて、TypeScriptのソースコードを同梱した場合

ファイル構造

.
├── main.ts
├── node_modules
│   └── your-package
│       ├── index.d.ts
│       ├── index.js
│       ├── index.ts … 追加
│       └── package.json
└── package.json

ファイルの中身

node_modules/your-package/index.ts
export default async () => {
  throw new Error("this is dummy error");
};

各ツールの動き

WebStorm → ✅TypeScriptソースコードにジャンプする

VS Code → 😕型定義ファイルにジャンプする

ts-node → 😕スタックトレースはJavaScript

ソースマップがないため当然

❯❯❯ node --loader ts-node/esm main.ts
(node:26945) ExperimentalWarning: --experimental-loader is an experimental feature. This feature could change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
Error: this is dummy error
    at file:///app/node_modules/your-package/index.js:11:11
    at Generator.next (<anonymous>)
    at file:///app/node_modules/your-package/index.js:7:71
    at new Promise (<anonymous>)
    at __awaiter (file:///app/node_modules/your-package/index.js:3:12)
    at default (file:///app/node_modules/your-package/index.js:10:22)
    at file:///app/main.ts:3:1
    at ModuleJob.run (node:internal/modules/esm/module_job:175:25)
    at async Loader.import (node:internal/modules/esm/loader:178:24)
    at async Object.loadESM (node:internal/process/esm_loader:68:5)

esbuildでバンドル+Chromeのコンソール → 😕スタックトレースはコンパイル後のJavaScript

ソースマップがないため当然。

npx esbuild main.ts --bundle --minify --sourcemap --outfile=esbuild.js

tsc→node --enable-source-maps → 😕スタックトレースはコンパイル後のJS

ソースマップがないため当然。

$ npx tsc && node --enable-source-maps main.js
Error: this is dummy error
    at file:///app/node_modules/your-package/index.js:11:11
    at Generator.next (<anonymous>)
    at file:///app/node_modules/your-package/index.js:7:71
    at new Promise (<anonymous>)
    at __awaiter (file:///app/node_modules/your-package/index.js:3:12)
    at default (file:///app/node_modules/your-package/index.js:10:22)
    at null.<anonymous> (/Volumes/v/suinplayground/research-the-best-source-map-settings-for-libraries/02-with-source-code/main.ts:3:1)
    at ModuleJob.run (node:internal/modules/esm/module_job:175:25)
    at async Loader.import (node:internal/modules/esm/loader:178:24)
    at async Object.loadESM (node:internal/process/esm_loader:68:5)

完全なコード

https://github.com/suinplayground/research-the-best-source-map-settings-for-libraries/tree/main/02-with-source-code

suinsuin

03: ソースマップは同梱、ソースコードは同梱されていない場合

ファイル構造

.
├── main.ts
├── node_modules
│   └── your-package
│       ├── index.d.ts … 型定義ファイル
│       ├── index.js … コンパイル後JS+ソースマップコメント
│       ├── index.js.map … ソースマップファイル
│       └── package.json
├── package.json
└── tsconfig.json

ファイルの内容

ライブラリのコンパイラオプション

tsconfig.json
{
  "compilerOptions": {
    "target": "es2015",
    "module": "es2015",
    "declaration": true,
    "sourceMap": true, // これがオン
    "esModuleInterop": true,
    "moduleResolution": "node"
  }
}

型定義ファイル

型定義ファイルにはソースマップコメントがない。

node_modules/your-package/index.d.ts
declare const _default: () => Promise<never>;
export default _default;

コンパイル後のJS

コンパイルオプションsourceMapを有効にしたことで、ファイル末尾にソースマップファイルへの参照//# sourceMappingURL=index.js.mapが追加された。

node_modules/your-package/index.js
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
    function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
    return new (P || (P = Promise))(function (resolve, reject) {
        function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
        function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
        function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
        step((generator = generator.apply(thisArg, _arguments || [])).next());
    });
};
export default () => __awaiter(void 0, void 0, void 0, function* () {
    throw new Error("this is dummy error");
});
//# sourceMappingURL=index.js.map

ソースマップファイル

sourcesでは"index.ts"を指しているが、index.tsは同梱されていない……。

node_modules/your-package/index.js.map
{
  "version": 3,
  "file": "index.js",
  "sourceRoot": "",
  "sources": ["index.ts"],
  "names": [],
  "mappings": ";;;;;;;;;AAAA,eAAe,GAAS,EAAE;IACxB,MAAM,IAAI,KAAK,CAAC,qBAAqB,CAAC,CAAC;AACzC,CAAC,CAAA,CAAC"
}

各ツールの動き

WebStorm → 😕型定義ファイルへのジャンプになる

VS Code → 😕型定義ファイルへのジャンプになる

esbuild→Chrome閲覧 → 😕スタックトレースはTSになるものの、TSソースコードは見れない

npx esbuild main.ts --bundle --minify --sourcemap --outfile=esbuild.js

✅スタックトレースには、ソースコードのファイル名index.tsと行数が現れる

😕しかし、スタックトレースをクリックしてソースコードを閲覧しようとすると「Could not load content for http://localhost:63342/03-with-source-map-without-source-code/node_modules/your-package/index.ts (HTTP error: status code 404, net::ERR_HTTP_RESPONSE_CODE_FAILURE)」というエラーになる。

ts-node → ✅スタックトレースはTSソースコードになる

ただし、ソースコードの当該箇所を見ようとしたら、GitHubなりを見に行かないといけないので、DX的には良くない。

$ node --loader ts-node/esm main.ts
(node:30700) ExperimentalWarning: --experimental-loader is an experimental feature. This feature could change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
Error: this is dummy error
    at file:///app/node_modules/your-package/index.ts:2:9
    at Generator.next (<anonymous>)
    at file:///app/node_modules/your-package/index.js:7:71
    at new Promise (<anonymous>)
    at __awaiter (file:///app/node_modules/your-package/index.js:3:12)
    at default (file:///app/node_modules/your-package/index.ts:1:27)
    at file:///app/main.ts:3:1
    at ModuleJob.run (node:internal/modules/esm/module_job:175:25)
    at async Loader.import (node:internal/modules/esm/loader:178:24)
    at async Object.loadESM (node:internal/process/esm_loader:68:5)

node --enable-source maps → ✅スタックトレースはTSソースコードになる

ただし、ソースコードの当該箇所を見ようとしたら、GitHubなりを見に行かないといけないので、DX的には良くない。

$ npx tsc && node --enable-source-maps main.js
Error: this is dummy error
    at null.<anonymous> (/app/node_modules/your-package/index.ts:2:9)
    at Generator.next (<anonymous>)
    at file:///app/node_modules/your-package/index.js:7:71
    at new Promise (<anonymous>)
    at __awaiter (file:///app/node_modules/your-package/index.js:3:12)
    at null.default (/app/node_modules/your-package/index.ts:1:27)
    at null.<anonymous> (/app/main.ts:3:1)
    at ModuleJob.run (node:internal/modules/esm/module_job:175:25)
    at async Loader.import (node:internal/modules/esm/loader:178:24)
    at async Object.loadESM (node:internal/process/esm_loader:68:5)

完全なコード

https://github.com/suinplayground/research-the-best-source-map-settings-for-libraries/tree/main/03-with-source-map-without-source-code

suinsuin

中間まとめ

型定義ファイルだけ

  • WebStormのコードジャンプ: ❌ (型定義ファイルへのジャンプ)
  • VS Codeのコードジャンプ: ❌ (型定義ファイルへのジャンプ)
  • ts-nodeのスタックトレース: -
  • esbuildでバンドル+Chromeコンソール: -
  • node --enable-source-maps: -

ソースコード同梱・ソースマップなし

  • WebStormのコードジャンプ: ✅
  • VS Codeのコードジャンプ: ❌ (型定義ファイルへのジャンプ)
  • ts-nodeのスタックトレース: ❌
  • esbuildでバンドル+Chromeコンソール: ❌
  • node --enable-source-maps: ❌

ソースコードなし・ソースマップ同梱("sourceMap": true)

  • WebStormのコードジャンプ: ❌ (型定義ファイルへのジャンプ)
  • VS Codeのコードジャンプ: ❌ (型定義ファイルへのジャンプ)
  • ts-nodeのスタックトレース: 😕 (スタックトレースはTSになるが、ソースコードの参照はできない)
  • esbuildでバンドル+Chromeコンソール: 😕 (スタックトレースはTSになるが、ソースコードの参照はできない)
  • node --enable-source-maps: 😕 (スタックトレースはTSになるが、ソースコードの参照はできない)
suinsuin

04: ソースコード同梱・ソースマップ同梱("sourceMap": true)

ソースコードとソースマップどちらもライブラリに同梱した場合。

ファイル構成

.
├── main.ts
├── node_modules
│   └── your-package
│       ├── index.d.ts … 型定義ファイル
│       ├── index.js … コンパイル後のJS
│       ├── index.js.map … ソースマップ
│       ├── index.ts … ソースコード
│       └── package.json
├── package.json
└── tsconfig.json

ファイルの内容

ライブラリのtsconfig

ライブラリに同梱する必要はない

node_modules/your-package/tsconfig.json
{
  "compilerOptions": {
    "target": "es2015",
    "module": "es2015",
    "declaration": true,
    "sourceMap": true, // これがオン
    "esModuleInterop": true,
    "moduleResolution": "node"
  }
}

ソースコード

TypeScriptのソースコードが同梱されている。

node_modules/your-package/index.ts
export default async () => {
  throw new Error("this is dummy error");
};

型定義ファイル

これもある

ソースマップファイル

03のケースとは異なり、今度はindex.tsがちゃんと参照できそうだ。

node_modules/your-package/index.js.map
{
  "version": 3,
  "file": "index.js",
  "sourceRoot": "",
  "sources": ["index.ts"],
  "names": [],
  "mappings": ";;;;;;;;;AAAA,eAAe,GAAS,EAAE;IACxB,MAAM,IAAI,KAAK,CAAC,qBAAqB,CAAC,CAAC;AACzC,CAAC,CAAA,CAAC"
}

各ツールの動き

WebStorm → ✅ソースコードにコードジャンプできる

VS Code→❌型定義ファイルへのジャンプになる

VS CodeはソースコードとソースマップだけではTypeScriptソースコードへはジャンプできないようだ。

ts-nodeのスタックトレース→✅スタックトレースがTSになる

😆ソースコードも付随しているので、スタックトレースから当該行を見るのが容易

$ node --loader ts-node/esm main.ts
(node:33075) ExperimentalWarning: --experimental-loader is an experimental feature. This feature could change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
Error: this is dummy error
    at file:///app/node_modules/your-package/index.ts:2:9
    at Generator.next (<anonymous>)
    at file:///app/node_modules/your-package/index.js:7:71
    at new Promise (<anonymous>)
    at __awaiter (file:///app/node_modules/your-package/index.js:3:12)
    at default (file:///app/node_modules/your-package/index.ts:1:27)
    at file:///app/main.ts:3:1
    at ModuleJob.run (node:internal/modules/esm/module_job:175:25)
    at async Loader.import (node:internal/modules/esm/loader:178:24)
    at async Object.loadESM (node:internal/process/esm_loader:68:5)

esbuildでバンドル+Chromeコンソール → ✅スタックトレースがTS、ソースコードも追える

npx esbuild main.ts --bundle --minify --sourcemap --outfile=esbuild.js

ただし、バンドル後JSのソースマップファイルを見ると、"node_modules/your-package/index.ts"のsourcesContentnullなので、HTTP越しにGET /node_modules/your-package/index.tsできない環境になっているとブラウザ側ではソースコードが追えなさそうだ。

esbuild.js.map
{
  "version": 3,
  "sources": ["node_modules/your-package/index.ts", "main.ts"],
  "sourcesContent": [null, "import func from \"your-package\";\n\nfunc();\n"],
  "mappings": "0TAAA,EAAe,IAAW,EAAA,OAAA,OAAA,OAAA,WAAA,CACxB,KAAM,IAAI,OAAM,yBCClB",
  "names": []
}

esbuildで--sourcemap=inlineにするとどうなるか?

npx esbuild main.ts --bundle --minify --sourcemap=inline --outfile=esbuild-inline.js

生成されるJS

esbuild-inline.js
(()=>{var p=function(u,d,i,o){function a(t){return t instanceof i?t:new i(function(c){c(t)})}return new(i||(i=Promise))(function(t,c){function h(n){try{f(o.next(n))}catch(e){c(e)}}function w(n){try{f(o.throw(n))}catch(e){c(e)}}function f(n){n.done?t(n.value):a(n.value).then(h,w)}f((o=o.apply(u,d||[])).next())})},r=()=>p(void 0,void 0,void 0,function*(){throw new Error("this is dummy error")});r();})();
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsibm9kZV9tb2R1bGVzL3lvdXItcGFja2FnZS9pbmRleC50cyIsICJtYWluLnRzIl0sCiAgInNvdXJjZXNDb250ZW50IjogW251bGwsICJpbXBvcnQgZnVuYyBmcm9tIFwieW91ci1wYWNrYWdlXCI7XG5cbmZ1bmMoKTtcbiJdLAogICJtYXBwaW5ncyI6ICIwVEFBQSxFQUFlLElBQVcsRUFBQSxPQUFBLE9BQUEsT0FBQSxXQUFBLENBQ3hCLEtBQU0sSUFBSSxPQUFNLHlCQ0NsQiIsCiAgIm5hbWVzIjogW10KfQo=

このsourceMappingURLをデコードする↓

{
  "version": 3,
  "sources": ["node_modules/your-package/index.ts", "main.ts"],
  "sourcesContent": [null, "import func from \"your-package\";\n\nfunc();\n"],
  "mappings": "0TAAA,EAAe,IAAW,EAAA,OAAA,OAAA,OAAA,WAAA,CACxB,KAAM,IAAI,OAAM,yBCClB",
  "names": []
}

結局、node_modules/your-package/index.tsに対応するsourcesContentnullなので、ブラウザがGET /node_modules/your-package/index.tsできる必要はありそうだ。

node --enable-source-maps → ✅スタックトレースがTSになっており、ソースコードもローカルで追える

$ npx tsc && node --enable-source-maps main.js
/app/node_modules/your-package/index.ts:2
  throw new Error("this is dummy error");
        ^

Error: this is dummy error
    at null.<anonymous> (/app/node_modules/your-package/index.ts:2:9)
    at Generator.next (<anonymous>)
    at file:///app/node_modules/your-package/index.js:7:71
    at new Promise (<anonymous>)
    at __awaiter (file:///app/node_modules/your-package/index.js:3:12)
    at null.default (/app/node_modules/your-package/index.ts:1:27)
    at null.<anonymous> (/app/main.ts:3:1)
    at ModuleJob.run (node:internal/modules/esm/module_job:175:25)
    at async Loader.import (node:internal/modules/esm/loader:178:24)
    at async Object.loadESM (node:internal/process/esm_loader:68:5)

完全なコード

https://github.com/suinplayground/research-the-best-source-map-settings-for-libraries/tree/main/04-with-source-code-and-source-map

suinsuin

Tips: webpackでライブラリのソースマップを利用する方法

webpackはesbuildと異なり、デフォルトではライブラリのソースマップファイルを利用しない。そのため、ソースマップファイルを提供しているライブラリであっても、webpackでビルドするとそのバンドルのソースマップはコンパイル後のJSを指すようになってしまう。

  1. source-map-loaderをインストールする
  2. webpack.config.jsにsource-map-loaderを組み込む
webpack.config.js
export default (env, argv) => {
  return {
    entry: "./main.ts",
    devtool: "source-map", // ここ
    module: {
      rules: [
        { test: /\.js$/, enforce: "pre", use: ["source-map-loader"] }, // ここ
        { test: /\.tsx?$/, use: "ts-loader", exclude: /node_modules/ },
      ],
    },
    resolve: {
      extensions: [".tsx", ".ts", ".js"],
    },
    output: {
      filename:
        argv.mode === "production"
          ? "webpack-production.js"
          : "webpack-development.js",
      path: process.cwd(),
    },
  };
};

この設定でビルドすると、ライブラリのソースマップが使われるようになる。

--mode=production

{
  "version": 3,
  "file": "webpack-production.js",
  "mappings": "6BAA0B,YACxB,MAAM,IAAIA,MAAM,wB,YADQ,K,kPAAA,E",
  "sources": ["webpack://01-poor-dx/./node_modules/your-package/index.ts"], // 🔴ここ
  "sourcesContent": [
    "export default async () => {\n  throw new Error(\"this is dummy error\");\n};\n" // 🔴ここ
  ],
  "names": ["Error"],
  "sourceRoot": ""
}

--mode=development

{
  "version": 3,
  "file": "webpack-development.js",
  "mappings": ";;;;;;;;;;;;;;;;;;;;;;;AAAA,iEAAe,GAAS,EAAE;IACxB,MAAM,IAAI,KAAK,CAAC,qBAAqB,CAAC,CAAC;AACzC,CAAC,GAAC;;;;;;;UCFF;UACA;;UAEA;UACA;UACA;UACA;UACA;UACA;UACA;UACA;UACA;UACA;UACA;UACA;UACA;;UAEA;UACA;;UAEA;UACA;UACA;;;;;WCtBA;WACA;WACA;WACA;WACA,yCAAyC,wCAAwC;WACjF;WACA;WACA;;;;;WCPA;;;;;WCAA;WACA;WACA;WACA,uDAAuD,iBAAiB;WACxE;WACA,gDAAgD,aAAa;WAC7D;;;;;;;;;;;;ACNgC;AAEhC,wDAAI,EAAE,CAAC",
  "sources": [
    "webpack://01-poor-dx/./node_modules/your-package/index.ts", // 🔴ここ
    "webpack://01-poor-dx/webpack/bootstrap",
    "webpack://01-poor-dx/webpack/runtime/define property getters",
    "webpack://01-poor-dx/webpack/runtime/hasOwnProperty shorthand",
    "webpack://01-poor-dx/webpack/runtime/make namespace object",
    "webpack://01-poor-dx/./main.ts"
  ],
  "sourcesContent": [
    "export default async () => {\n  throw new Error(\"this is dummy error\");\n};\n", // 🔴ここ
    "// The module cache\nvar __webpack_module_cache__ = {};\n\n// The require function\nfunction __webpack_require__(moduleId) {\n\t// Check if module is in cache\n\tvar cachedModule = __webpack_module_cache__[moduleId];\n\tif (cachedModule !== undefined) {\n\t\treturn cachedModule.exports;\n\t}\n\t// Create a new module (and put it into the cache)\n\tvar module = __webpack_module_cache__[moduleId] = {\n\t\t// no module.id needed\n\t\t// no module.loaded needed\n\t\texports: {}\n\t};\n\n\t// Execute the module function\n\t__webpack_modules__[moduleId](module, module.exports, __webpack_require__);\n\n\t// Return the exports of the module\n\treturn module.exports;\n}\n\n",
    "// define getter functions for harmony exports\n__webpack_require__.d = (exports, definition) => {\n\tfor(var key in definition) {\n\t\tif(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {\n\t\t\tObject.defineProperty(exports, key, { enumerable: true, get: definition[key] });\n\t\t}\n\t}\n};",
    "__webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))",
    "// define __esModule on exports\n__webpack_require__.r = (exports) => {\n\tif(typeof Symbol !== 'undefined' && Symbol.toStringTag) {\n\t\tObject.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });\n\t}\n\tObject.defineProperty(exports, '__esModule', { value: true });\n};",
    "import func from \"your-package\";\n\nfunc();\n"
  ],
  "names": [],
  "sourceRoot": ""
}
suinsuin

中間まとめ

01. 型定義ファイルだけ

  • WebStormのコードジャンプ: ❌ (型定義ファイルへのジャンプ)
  • VS Codeのコードジャンプ: ❌ (型定義ファイルへのジャンプ)
  • ts-nodeのスタックトレース: -
  • esbuildでバンドル+Chromeコンソール: -
  • node --enable-source-maps: -

02. ソースコード同梱・ソースマップなし

  • WebStormのコードジャンプ: ✅
  • VS Codeのコードジャンプ: ❌ (型定義ファイルへのジャンプ)
  • ts-nodeのスタックトレース: ❌
  • esbuildでバンドル+Chromeコンソール: ❌
  • node --enable-source-maps: ❌

03. ソースコードなし・ソースマップ同梱("sourceMap": true)

  • WebStormのコードジャンプ: ❌ (型定義ファイルへのジャンプ)
  • VS Codeのコードジャンプ: ❌ (型定義ファイルへのジャンプ)
  • ts-nodeのスタックトレース: 😕 (スタックトレースはTSになるが、ソースコードの参照はできない)
  • esbuildでバンドル+Chromeコンソール: 😕 (スタックトレースはTSになるが、ソースコードの参照はできない)
  • node --enable-source-maps: 😕 (スタックトレースはTSになるが、ソースコードの参照はできない)

04: ソースコード同梱・ソースマップ同梱("sourceMap": true)

  • WebStormのコードジャンプ: ✅
  • VS Codeのコードジャンプ: ❌ (型定義ファイルへのジャンプ)
  • ts-nodeのスタックトレース: ✅
  • esbuildでバンドル+Chromeコンソール: ✅ (ただしHTTP越しにGET /node_modules/**/*.tsできる必要あり)
  • node --enable-source-maps: ✅

あとはVS Codeだけなんとかなればいい

suinsuin

05: ソースコード同梱・ソースマップ同梱("sourceMap": true)・型定義ソースマップ同梱("declarationMap": true)

ファイル構成

.
├── main.ts
├── node_modules
│   └── your-package
│       ├── index.d.ts … 型定義ファイル
│       ├── index.d.ts.map … 型定義のソースマップ
│       ├── index.js … コンパイル後JS
│       ├── index.js.map … ソースマップ
│       ├── index.ts … TypeScriptソースコード
│       └── package.json
└── package.json

ファイルの内容

ライブラリのtsconfig

{
  "compilerOptions": {
    "target": "es2015",
    "module": "es2015",
    "declaration": true, // 型定義ファイルを生成する
    "declarationMap": true, // 型定義ソースマップを生成する
    "sourceMap": true, // ソースマップを生成する
    "esModuleInterop": true,
    "moduleResolution": "node"
  }
}

型定義ファイル

declarationMapを有効にしたことで、sourceMappingURLがコメントで追加された。

node_modules/your-package/index.d.ts
declare const _default: () => Promise<never>;
export default _default;
//# sourceMappingURL=index.d.ts.map

型定義ソースマップファイル

index.tsを参照する形でソースマップが生成された。

node_modules/your-package/index.d.ts.map
{
  "version": 3,
  "file": "index.d.ts",
  "sourceRoot": "",
  "sources": ["index.ts"],
  "names": [],
  "mappings": ";AAAA,wBAEE"
}

コンパイル後JS

上のケースと変わらない。

node_modules/your-package/index.js
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
    function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
    return new (P || (P = Promise))(function (resolve, reject) {
        function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
        function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
        function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
        step((generator = generator.apply(thisArg, _arguments || [])).next());
    });
};
export default () => __awaiter(void 0, void 0, void 0, function* () {
    throw new Error("this is dummy error");
});
//# sourceMappingURL=index.js.map

ソースマップファイル

これも上のケースと変わらない。

node_modules/your-package/index.js.map
{
  "version": 3,
  "file": "index.js",
  "sourceRoot": "",
  "sources": ["index.ts"],
  "names": [],
  "mappings": ";;;;;;;;;AAAA,eAAe,GAAS,EAAE;IACxB,MAAM,IAAI,KAAK,CAAC,qBAAqB,CAAC,CAAC;AACzC,CAAC,CAAA,CAAC"
}

各ツールの動き

WebStormのコードジャンプ → ✅TypeScriptソースコードにジャンプできる

VS Codeのコードジャンプ → ✅TypeScriptソースコードにジャンプできる

VS CodeはWebStormと違い、型定義ソースマップがあると、TSソースコードにジャンプする仕様のようだ。

ts-nodeのスタックトレース → ✅スタックトレースにはTSのファイル名・行が現れる

04のケースと同じ。

esbuildでバンドル+Chromeコンソール → ✅スタックトレースはTSのファイル名・行が現れる

04のケースと同じ。

node --enable-source-maps → ✅スタックトレースはTSのファイル名・行が現れる

04のケースと同じ。

完全なコード

https://github.com/suinplayground/research-the-best-source-map-settings-for-libraries/tree/main/05-with-source-code-and-source-map-and-declaration-map

suinsuin

中間まとめ

01. 型定義ファイルだけ

  • WebStormのコードジャンプ: ❌ (型定義ファイルへのジャンプ)
  • VS Codeのコードジャンプ: ❌ (型定義ファイルへのジャンプ)
  • ts-nodeのスタックトレース: -
  • esbuildでバンドル+Chromeコンソール: -
  • node --enable-source-maps: -

02. ソースコード同梱・ソースマップなし

  • WebStormのコードジャンプ: ✅
  • VS Codeのコードジャンプ: ❌ (型定義ファイルへのジャンプ)
  • ts-nodeのスタックトレース: ❌
  • esbuildでバンドル+Chromeコンソール: ❌
  • node --enable-source-maps: ❌

03. ソースコードなし・ソースマップ同梱("sourceMap": true)

  • WebStormのコードジャンプ: ❌ (型定義ファイルへのジャンプ)
  • VS Codeのコードジャンプ: ❌ (型定義ファイルへのジャンプ)
  • ts-nodeのスタックトレース: 😕 (スタックトレースはTSになるが、ソースコードの参照はできない)
  • esbuildでバンドル+Chromeコンソール: 😕 (スタックトレースはTSになるが、ソースコードの参照はできない)
  • node --enable-source-maps: 😕 (スタックトレースはTSになるが、ソースコードの参照はできない)

04: ソースコード同梱・ソースマップ同梱("sourceMap": true)

  • WebStormのコードジャンプ: ✅
  • VS Codeのコードジャンプ: ❌ (型定義ファイルへのジャンプ)
  • ts-nodeのスタックトレース: ✅
  • esbuildでバンドル+Chromeコンソール: ✅ (ただしHTTP越しにGET /node_modules/**/*.tsできる必要あり)
  • node --enable-source-maps: ✅

05: ソースコード同梱・ソースマップ同梱("sourceMap": true)・型定義ソースマップ同梱("declarationMap": true)

  • WebStormのコードジャンプ: ✅
  • VS Codeのコードジャンプ: ✅
  • ts-nodeのスタックトレース: ✅
  • esbuildでバンドル+Chromeコンソール: ✅ (ただしHTTP越しにGET /node_modules/**/*.tsできる必要あり)
  • node --enable-source-maps: ✅
suinsuin

結論

良いDXをライブラリユーザーに提供するために、TypeScriptライブラリのtsconfig設定はどうあるべきか?

コンパイラオプションsourceMaptrueにする。

  • スタックトレースがTypeScriptのファイル名・行になる

コンパイラオプションdeclarationMaptrueにする。

  • VS Codeでコードジャンプができるようになる

ソースコードのTypeScriptも同梱する。

  • WebStormでコードジャンプができるようになる
  • Google ChromeのインスペクタでスタックトレースからTypeScriptソースコードが見れるようになる
suinsuin

そういえば、inlineSourceMapってどうなん?

inlineSourceMapとは

ファイル構成

.
├── main.ts
├── node_modules
│   └── your-package
│       ├── index.d.ts
│       ├── index.d.ts.map
│       ├── index.js
│       ├── index.ts
│       └── package.json
└── package.json

基本的には05と同じ。違いは、index.js.mapが生成されないこと。

ファイルの内容

基本的には05と同じ。違いは以下。

ライブラリ側のtsconfig

{
  "compilerOptions": {
    "target": "es2015",
    "module": "es2015",
    "declaration": true,
    "declarationMap": true,
//    "sourceMap": true,
    "inlineSourceMap": true, // これを有効化
    "esModuleInterop": true,
    "moduleResolution": "node"
  }
}

sourceMapinlineSourceMapは併用できない。

コンパイル後JavaScript

ソースマップコメントがBase64になっている。

node_modules/your-package/index.js
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
    function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
    return new (P || (P = Promise))(function (resolve, reject) {
        function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
        function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
        function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
        step((generator = generator.apply(thisArg, _arguments || [])).next());
    });
};
export default () => __awaiter(void 0, void 0, void 0, function* () {
    throw new Error("this is dummy error");
});
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyJpbmRleC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiOzs7Ozs7Ozs7QUFBQSxlQUFlLEdBQVMsRUFBRTtJQUN4QixNQUFNLElBQUksS0FBSyxDQUFDLHFCQUFxQixDQUFDLENBQUM7QUFDekMsQ0FBQyxDQUFBLENBQUMifQ==

このBase64をデコードすると次の内容になる

{
  "version": 3,
  "file": "index.js",
  "sourceRoot": "",
  "sources": ["index.ts"],
  "names": [],
  "mappings": ";;;;;;;;;AAAA,eAAe,GAAS,EAAE;IACxB,MAAM,IAAI,KAAK,CAAC,qBAAqB,CAAC,CAAC;AACzC,CAAC,CAAA,CAAC"
}

これは05のindex.js.mapの内容と同じ。

inlineSourceMaptrueにしても、TypeScriptソースコードがコンパイル後JSにインライン化されるわけではない

なので、inlineSourceMapにせよsourceMapにせよ、ライブラリ作者はソースコードとなるTypeScriptを同梱する必要がある。

suinsuin

結論

良いDXをライブラリユーザーに提供するために、TypeScriptライブラリのtsconfig設定はどうあるべきか?

コンパイラオプションsourceMapまたはinlineSourceMaptrueにする。

  • スタックトレースがTypeScriptのファイル名・行になるので、コードが追いやすくなる。
    • 以下のツールが出力するスタックトレースに対応できる
      • Google Chromeのコンソールなど
      • node --enable-source-maps
      • ts-node

コンパイラオプションdeclarationMaptrueにする。

  • WebStormとVS Codeの両方でコードジャンプができるようになる

ソースコードのTypeScriptも同梱する。

  • WebStormでコードジャンプができるようになる
  • Google ChromeのインスペクタなどでスタックトレースからTypeScriptソースコードが見れるようになる