アカベコマイリ

HEAR NOTHING SEE NOTHING SAY NOTHING

npm を TypeScript で実装する

自作 npm xlsx-extractor を JavaScript から TypeScript へ移行した際の覚書。はじめて npm 開発した時の npm icon-gen v1.0.0 release を踏まえて、新規 npm を TypeScript で実装する場合の入門記事として機能することを考慮しながら書く。

パッケージ仕様と開発方針

開発対象となる xlsx-extractor の仕様と開発方針をまとめる。

仕様

  • Excel ファイル (*.xlsx 形式) を解析してシート情報とセルを取得する
  • 空のセルは詰めずに最大の列と行で埋める
    • Excel 上のセル座標を維持する
    • 例えば B7 に値がある場合、0 開始の座標で B = 1 7 = 6 として常に [1][6] で取得可能とする
  • インターフェースは Node.js 用と CLI の 2 種類へ対応
    • CLI は標準出力にシート内容を JSON 形式で出力する

開発方針

  • TypeScript で実装、Node.js LTS (現時点の最新は 10) 向け JavaScript と型ファイルをビルドして提供
    • 型ファイルをパッケージに同梱することで @types/XXXX なしに npm 単体で型を参照可能となる
    • 本パッケージを npm として参照する際に vscode 上の開発が楽になる
  • ユニット テストを書く
    • テスト ランナーは Jest を採用
    • テストは元コードと併置する、例えば index.ts のテストは同一階層の index.test.ts
  • 継続的にテストを自動実行する
    • サービスは Travis CI を採用
    • OSS なら無料
  • コード書式を明示的に管理する
    • Prettier を採用
    • ESLint については TypeScript のチェックが優秀なのと書式面は Prettier が担うので見送り
    • ESLint + Prettier on TypeScript でよい感じの設定を構築できたら改めて導入するかも
  • README.md だけでパッケージの機能を把握できるようにする
  • CHANGELOG.md に更新履歴を記録する
  • LICENSE ファイルにライセンスを明記する、本プロジェクトの場合は MIT
  • examples/ に実動サンプルを公開する
    • Node.js と TypeScript 向けを用意
    • TypeScript 版を vscode で開けば型情報の便利さを実感してもらえるはず

プロジェクトのファイル構成

プロジェクトのファイル構成をまとめる。

.
├── dist/
├── examples/
├── src/
│   ├── bin/
│   └── lib/
├── .gitignore
├── .prettierrc
├── .travis.yml
├── jest.config.js
├── LICENSE
├── CHANGELOG.md
├── README.md
├── package.json
└── tsconfig.json

それぞれの役割は以下。

名前内容
dist/TypeScript からビルドされたリリース用 JavaScript や型ファイルを格納するディレクトリー。
examples/パッケージの実動サンプルを格納するディレクトリー。
src/TypeScript による実装コードを格納するディレクトリー。
src/bin/CLI インターフェース実装ディレクトリー。
src/lib/パッケージ本体の実装ディレクトリー。
.gitignoreGit 管理から除外するファイルとディレクトリーの設定ファイル。
.prettierrcコード フォーマッター Prettier の設定ファイル。
.travis.yaml継続的インテグレーション サービス Travis CI の設定ファイル。
jest.config.jsテスト フレームワーク Jest の設定ファイル。
LICENSEパッケージのライセンス ファイル。
CHANGELOG.mdパッケージの更新履歴ファイル。
README.mdパッケージ情報ファイル。
package.jsonプロジェクト設定ファイル。
tsconfig.jsonTypeScript 設定ファイル。

ソース コードの格納先として開発用を src/、ビルドされたものは dist とする理由について。

ビルドせず開発用のソース コードを直に公開せず (できず) TypeScript や Banel によるビルドを経由するならルート階層を分けることで、ビルド対象と出力 (公開) するもののフィルター処理がおこないやすくなる。

実際の効能は後述する package.json や tsconfig.json の解説を読めば理解できるだろう。例えばビルド出力先を dist/ というディレクトリー配下にすることで、package.json の files 設定をこれのみで済ませている。

プロジェクト設定

プロジェクト設定となる package.json 定義。

{
  "name": "xlsx-extractor",
  "description": "Extract the colums/rows from XLSX file.",
  "version": "1.4.1",
  "author": "akabeko (http://akabeko.me/)",
  "license": "MIT",
  "homepage": "https://github.com/akabekobeko/npm-xlsx-extractor#readme",
  "engines": {
    "node": ">= 10"
  },
  "main": "dist/lib/index.js",
  "bin": "dist/bin/index.js",
  "files": [
    "dist"
  ],
  "keywords": [
    "XLSX",
    "Excel",
    "Extract"
  ],
  "repository": {
    "type": "git",
    "url": "git+https://github.com/akabekobeko/npm-xlsx-extractor.git"
  },
  "bugs": {
    "url": "https://github.com/akabekobeko/npm-xlsx-extractor/issues"
  },
  "scripts": {
    "test": "jest",
    "start": "npm run watch",
    "tsc": "tsc --noEmit",
    "build": "tsc",
    "watch": "tsc -w",
    "prepare": "npm run build"
  },
  "dependencies": {
    "commander": "^2.20.0",
    "node-zip": "^1.1.1",
    "xml2js": "^0.4.19"
  },
  "devDependencies": {
    "@types/jest": "^24.0.15",
    "@types/node": "^12.0.10",
    "@types/xml2js": "^0.4.4",
    "jest": "^24.8.0",
    "ts-jest": "^24.0.2",
    "typescript": "^3.5.2"
  }
}

それぞれの役割。

設定解説
nameパッケージ名。
descriptionパッケージが提供する機能の説明。
versionバージョン情報。
author作者。
licenseライセンス情報。
homepageプロジェクトのホームページ URL。
engines動作環境。今回は Node.js 向けなので、それ以降を指定。
mainパッケージがモジュール参照 (import や require) された際のエントリー ポイントになるファイル。ビルドで出力されたものを指定。
binパッケージが CLI として参照された際のエントリー ポイントになるファイル。ビルドで出力されたものを指定。
filesパッケージを公開する際に package.json などの必須ファイル以外で対象に含めるもの。ビルド成果物の出力先ディレクトリーのみを指定。
keywordsパッケージの機能や内容をあらわすキーワード。パッケージ マネージャーによる検索などで参照される。
repositoryソース コードの格納場所。本リポジトリーは GitHub で運用しているため、その情報を指定。
bugsパッケージのバグ情報などが公開されている場所。GitHub 運用しているため、その Issues を指定。
scriptsnpm-scripts。開発用の処理をシェル コマンドとして定義。ここに定義されたものは npm run XXXX で呼び出せる。start や test などの特別な予約済みのものは run を省略して npm start とすることも可能。
dependenciesパッケージが依存する npm のうち、ユーザー環境でも必要なもの。npm i でインストールするとこちらへ追加される。
devDependenciesパッケージが依存する npm のうち、開発環境のみが必要とするもの。npm i -D でインストールするとこちらへ追加される。例えば TypeScritp ビルドやユニット テストは開発に使用するものでユーザーには不要。

TypeScript ビルド設定

TypeScript をビルドする方法は大まかに二通り。

  1. typescript の tsc コマンド
  2. Babel + @babel/preset-typescript

Web フロントエンド開発だと複数 Web ブラウザーの ES (ECMAScript) 実装差を埋めるために @babel/preset-env を利用したいから 2 を選んでいる。しかし今回の npm は Node.js 専用なので単一環境だけ考慮すればよい。そのため 1 を採用した。

TypeScript に関する設定は tscofig.json へ定義する。

{
  "compilerOptions": {
    "target": "es2017",
    "module": "commonjs",
    "moduleResolution": "node",
    "outDir": "dist",
    "strict": true,
    "allowSyntheticDefaultImports": true,
    "declaration": true,
    "esModuleInterop": true,
    "sourceMap": false
  },
  "include": ["src/**/*"],
  "exclude": ["src/**/*.test.ts"]
}

compilerOptions の内訳。

設定解説
targetコンパイルされた JavaScript の対象環境。今回は Node.js 10 を想定している。Node.js ES2015/ES6, ES2016 and ES2017 support を見ると ES2017 が最新の安全圏と判断して採用。
moduleコンパイル後のモジュール参照方法。Node.js 向けにコンパイルするので commonjs を選択。
moduleResolution開発用ソース コードにおけるモジュール参照の解決方法。Node.js 向け開発なので node を選択。
outDirコンパイルされた成果物の出力先ディレクトリー。dist/ を指定。
strictnoImplicitAny など厳密な型チェック系の設定をすべて有効にするフラグ。こうしたルールは「基本を厳しく例外的にゆるめる」としておくのが安全なので有効化。
allowSyntheticDefaultImportsdefalt export のないモジュールからの暗黙的な import を許可するフラグ。型チェックにしか使用されないそうだが、有効化。
declarationコンパイル時に型情報ファイル *.d.ts を生成するフラグ。npm 単体で型情報を提供するため添付したいので有効化。
esModuleInteropモジュール参照における CommonJS と ES Modules の相互運用フラグ。Node.js 向けなので有効化。
sourceMapSource Maps を生成するフラグ。不要なので無効化。vscode の Node.js デバッガーや Web ブラウザー向け開発で DevTools デバッガーを利用したいなら生成する。

その他の設定。

設定解説
includeコンパイル対象の指定。開発用ソース コードは src/ に配置するので、それ以下のすべてを対象。
excludeコンパイル対象から除外するものの指定。ユニット テスト用ファイルすべてを指定。

npm-scripts

ビルド処理などを定義した npm-scripts について。

{
  "scripts": {
    "test": "jest",
    "start": "npm run watch",
    "tsc": "tsc --noEmit",
    "build": "tsc",
    "watch": "tsc -w",
    "prepare": "npm run build"
  }
}

TypeScript に関する処理は npm typescript が提供する CLI tsc を利用する。tsc は tsconfig.json を参照するため、共通設定はそちらへ集約しておく。目的に応じて分岐したい設定だけ npm-scripts で指定する方針。各コマンドの役割をまとめる。

コマンド役割
testJest によるユニット テストを実行する。
startwatch コマンドを間接的に呼び出す。
tscTypeScritp をビルドせず、コンパイル チェックだけ実行する。
buildTypeScript をビルドする。
watchTypeScript ビルドをファイル監視モードで起動する。対象ファイルが保存されるたびに逐次、差分ビルドが実行される。モード終了は Control + C。

ユニット テスト

ユニット テストは Jest を採用。TypeScritp を対象とする場合なら npm として jest と ts-jest が必要。設定は jest.config.js に定義する。

module.exports = {
  verbose: true,
  transform: {
    '^.+\\.ts$': 'ts-jest'
  }
}

設定内容は以下。

設定内容
verboseテスト実行時、内訳を詳細に表示。有効にするとテスト コード中の console も出力される。
transformテスト対象の変換設定。TypeScript ファイルを対象として ts-jest で変換するように指定。

テストと対象コードは同じディレクトリーに併置している。例えば index.ts に対するテストは index.test.ts とした。開発中はテストと対象コードを往復しがちなので、これらの距離が近いとアクセスしやすくて便利。この運用を想定してか Jest は標準で *.test.js や *.test.ts を暗黙的にテストとみなす。

テスト コード記法は Mocha 風の describe - it 形式を採用。チェックも expect ではなく assert にした。これは、長く Mocha を利用して馴染みがあるのと記法の互換を高めておけば相互に乗り入れるのが容易になるだろう、という理由による。例えば

import assert from 'assert'
import { createEmptyCells, getSheetSize, numOfColumn } from './xlsx-util'

describe('XlsxUtil', () => {
  describe('createEmptyCells', () => {
    it('Create empty cells', () => {
      const rows = 10
      const cols = 5
      const cells = createEmptyCells(rows, cols)
      assert.strictEqual(cells.length, rows)
      assert.strictEqual(cells[0].length, cols)
    })
  })
})

は TypeScript か ES Modules が有効な環境なら Jest と Mocha どちらでも動く。なお assert にしてもテスト失敗時は Jest による詳細なエラー情報が表示されるので使用感はそのままだ。

動作検証

実際にパッケージを動作させての検証について。

npm pack

公開されたパッケージをインストールして参照した状態を再現するためコマンドとして npm pack を利用可能。これをプロジェクトのルートで実行すると xlsx-extractor-1.4.1.tgz のようにパッケージ名とバージョンで命名された *.tgz ファイルが生成される。

$ npm pack

...中略
xlsx-extractor-1.4.1.tgz

このファイルを別の Node.js プロジェクトにコピーして以下のコマンドを実行。パッケージ名の代わりにファイルを指定している点に注目。

$ npm i xlsx-extractor-1.4.1.tgz

するとまるで公開されたパッケージのように追加される。

{
  "dependencies": {
    "xlsx-extractor": "file:xlsx-extractor-1.4.1.tgz"
  }
}

参照も import xlsx-extractor や require('xlsx-extractor') で OK。あとはエンド ユーザーとしてパッケージ利用したことを想定したコードを書いて検証すればよい。なお検証コードが TypeScript なら、パッケージに同梱した型情報ファイルが機能しているかを vscode 上で確認可能。

型情報の表示

npm pack で生成した npm をインストールすると dependencies 内のバージョン値こそ特殊になるが、アンインストールは通常どおり npm un xlsx-extractor でよい。

公開されたパッケージを CLI として呼び出した状態を再現するためのコマンドとして npm link がある。これをプロジェクトのルートで実行すると、ローカル PC のグローバルな npm としてプロジェクトにリンクを貼ってくれる。

$ npm link

...中略
/usr/local/bin/xlsx-extractor -> /usr/local/lib/node_modules/xlsx-extractor/dist/bin/index.js
/usr/local/lib/node_modules/xlsx-extractor -> /Users/XXXX/Documents/dev/node/xlsx-extractor

この状態で CLI を呼び出してみると検証がおこなえる。ただしプロジェクト側のコードを編集してビルドしても反映されないので、なにか修正したら npm link を再実行すること。

$ xlsx-extractor -v
1.4.1

リンクを解除したい場合はプロジェクトのルートで npm unlink を実行。

$ npm unlink
removed 1 package in 1.682s

実際の開発は以下のような感じになるだろう。

  1. npm start または npm run watch でビルドをファイル監視モードで起動
  2. コードを編集して保存
  3. 検証したいビルドができたら npm link
  4. 2 〜 3 を繰り返す
  5. ひととおり検証したらファイル監視モードを終了させて npm unlink

examples/

ここまでの手順を実行しやすいように examples/ ディレクトリーを用意する。本プロジェクトの構成は以下。

.
├── README.md
├── index.js
├── index.ts
├── package.json
└── sample.xlsx
名前内容
README.mdこのディレクトリーを利用した検証方法の解説書。
index.jsNode.js としての検証コード。
index.tsTypeScript with Node.js としての検証コード。
package.jsonプロジェクト設定ファイル。
sample.xlsx検証に使用するサンプル Excel ファイル。

package.json の定義。

{
  "name": "xlsx-extractor-examples",
  "description": "Examples for the xlsx-extractor.",
  "private": true,
  "version": "1.0.0",
  "author": "akabeko (http://akabeko.me/)",
  "license": "MIT",
  "main": "index.js",
  "scripts": {
    "js": "node index.js",
    "ts": "ts-node index.ts"
  },
  "dependencies": {
    "ts-node": "^8.3.0",
    "typescript": "^3.5.2",
    "xlsx-extractor": "^1.4.0"
  }
}

xlsx-extractor はパッケージ公開されたものを参照する。例えば素の Node.js 向けだとこんな感じ。

const xlsx = require('xlsx-extractor')

// Get sheets count
console.log(xlsx.getSheetCount('./sample.xlsx'))

// Single
xlsx
  .extract('./sample.xlsx', 1)
  .then((sheet) => {
    console.log(sheet)
  })
  .catch((err) => {
    console.log(err)
  })

example/ はエンド ユーザー環境を想定している。そのためパッケージ本体のバージョンを更新したら、こちらへも反映すること。

検証コードを直に実行せず npm-scripts 経由としている点も重要。npm-scripts はプロジェクトのローカルにインストールされた npm を参照可能なので、検証に必要なものを package.json に定義しておけば examples/ 内で環境が完結する。環境構築の手順も簡単で

  1. cd examples
  2. npm i

を実行するだけ。以降は npm run js と npm run ts を実行すれば検証になる。開発時の利用方法は以下のような感じ。

  • Node.js 向けは検証コードから直に ../dist/bin/index.js を参照して検証、きりのよいところで npm pack したものを参照 (通常のパッケージ参照へ戻す) して再検証
  • CLI 向けは npm link 後にここのサンプル データを利用して検証

型情報のない npm 参照について

tsconfig.json で "strict": true や個別に "noImplicitAny": true した場合、外部モジュールを参照した際に型情報ファイルが同梱されていないとエラーになる。その際は @types から追加してゆくのだが (vscode からもそのように促される)、ここにも公開されていなければ以下の方法で対応可能。

  • tsconfig.json で "noImplicitAny": false にする
  • declare module 'モジュール名' を定義する
    • プロジェクト内でグローバルに any 扱いになる
    • これをどこに定義するか?というのを決めるのが難しい
    • src/@types/index.d.ts とかに定義するのを見かける
    • declare module を定義したファイルに export XXXX を併記するとエラーになる問題あり
  • import の代わりに require で参照する
    • 参照したファイル内でのみ any 扱いになる
    • 影響範囲が小さく定義位置に迷うこともないので declare module よりもこちらのほうが好み
    • 本プロジェクトでもこれを採用、node-zip 参照を require にしている

まとめ

今回の npm は規模の小さなプロジェクトだが、それでも TypeScript 導入の効果を実感できた。具体的には

  • TypeScript で書き直すことにより型が明示され、コードの可読性が向上した
  • 型が強制されることでダックタイピング任せの雑な設計を見直す機会を得られた
  • vscode 上の開発体験が向上
    • TypeScript 標準で ESLint 並に警告やエラーをリアルタイムに指摘してくれる
    • JavaScript でも JSDoc などから型推論してくれるが TypeScript のほうが圧倒的に正確ではやい

という感じ。

以前の Babel を利用した開発環境と比べてて後退した部分としては @babel/preset-env による明示的な Node.js バージョン指定ぐらい。

これについては Node.js なら単体の下限バージョンを想定すればよいので、その安全圏になる ES バージョンを大雑把に指定しておけば十分。Node.js としても重要度の高い機能は積極的に対応されているので、普通に書いている分には非互換の問題には遭遇しないだろう。

よい感触が得られたので、他の自作 npm も順次 TypeScript 化してゆく予定。