あるSEのつぶやき・改

ITやシステム開発などの技術に関する話題を、SEとしての経験から取り上げたり解説したりしています。

TypeScriptで外部プロジェクトの参照設定とエイリアスをつける方法

はじめに

TypeScript には プロジェクト参照(Project References) という、別プロジェクトのソースコードを読み込んで使用できる仕組みがあります。

ただ、このプロジェクト参照は、以下のように 1 つの tsconfig.json を持つ大きなプロジェクトがほぼ前提になっているようです。調べると大体この構成です。

.
├── projectA # 共通プロジェクト
├── projectB # projectAを参照
├── projectC # projectAを参照
└── tsconfig.json

ただ、自分がやりたかったのは、以下のように各プロジェクトが tsconfig.json を持つ独立した構成にしたかったのですよね。

.
├── projectA # 共通プロジェクト
│   └── tsconfig.json
├── projectB # projectAを参照
│   └── tsconfig.json
└── projectC # projectAを参照
    └── tsconfig.json

この構成になると、途端に情報がなくなるのはなぜでしょうか。

公開したくないライブラリを参照する開発って、いくらでもある気がしますが。。

この記事では、以下の内容を扱います。

  • TypeScript で外部プロジェクトを参照する方法
  • TypeScript で外部プロジェクトにエイリアス(別名)をつける方法

なお、分量が多くなってしまうので、適宜折りたたみを入れています。

前提条件

この記事は以下を前提条件とします。

  • Node.js や TypeScript はインストール済み
  • パッケージマネージャーは npm を使用
  • Mac での手順になるので Windows の方は読み替えが必要

システム構成

この記事で扱う内容は、以下のシステム構成になります。

.
├── common-entity # 参照先プロジェクト
│   ├── dist # ビルド後ファイル
│   ├── src  # ソース(TyepeScript)
│   └── tsconfig.json # 設定ファイル
└── reference-proj # 参照元プロジェクト
    ├── dist # ビルド後ファイル
    ├── src  # ソース(TyepeScript)
    └── tsconfig.json # 設定ファイル

また、ビルドは以下のように行います。

  • common-entity: TypeScript の tsc --build
  • reference-proj: babel + webpack で npm run build

初期設定

プロジェクトの作成

下記のように、 common-entityreference-proj のプロジェクトを作成します。

プロジェクトの作成

# common-entity の作成
$ mkdir common-entity
$ cd common-entity
$ mkdir dist
$ mkdir src
$ mkdir test
$ npm init # 回答はデフォルトで OK
$ tsc --init

# reference-proj の作成
$ mkdir reference-proj
$ cd reference-proj
$ mkdir dist
$ mkdir src
$ mkdir test
$ npm init # 回答はデフォルトで OK
$ tsc --init

パッケージのインストール(common-entity, reference-proj 共通)

common-entityreference-proj の両方のプロジェクトに以下のパッケージをインストールします。

パッケージのインストール(common-entity, reference-proj 共通)

# ESLint, Prettier
$ npm install --save-dev eslint
$ npm install --save-dev prettier eslint-config-prettier
$ npm install --save-dev @typescript-eslint/eslint-plugin
$ npm install --save-dev eslint-config-standard eslint-plugin-import eslint-plugin-n eslint-plugin-promise

パッケージのインストール(reference-proj のみ)

reference-proj にのみ、以下のパッケージをインストールします。

パッケージのインストール(reference-proj のみ)

# jest
$ npm install --save-dev jest ts-jest @types/jest
$ npm install --save-dev eslint-plugin-jest


# babel
$ npm install --save-dev babel-jest @babel/core @babel/preset-env
$ npm install --save-dev @babel/preset-typescript
$ npm install --save-dev @babel/core @babel/preset-typescript babel-loader


# webpack
$ npm install --save-dev webpack webpack-cli
$ npm install --save node-polyfill-webpack-plugin
$ npm install --save tsconfig-paths-webpack-plugin

プロジェクトの設定(common-entity, reference-proj 共通)

common-entityreference-proj の両方のプロジェクトに以下の設定を行います。

プロジェクトの設定(common-entity, reference-proj 共通)

# シングルクォーテーションを強制する
$ touch .prettierrc.json
$ vim .prettierrc.json # 以下の内容を設定

{
  "singleQuote": true
}


# Lint の設定
$ touch .eslintrc.js
$ vim .eslintrc.js  # 以下の内容を設定

module.exports = {
    env: {
        browser: true,
        es2021: true,
        "jest/globals": true,
    },
    extends: [
        "standard",
        "prettier", // 他の設定の上書きを行うために、必ず最後に配置する。
    ],
    parser: "@typescript-eslint/parser",
    parserOptions: {
        ecmaVersion: "latest",
        sourceType: "module",
    },
    plugins: ["@typescript-eslint", "jest"],
    rules: {},
};


# TypeScript の設定
$ vim tsconfig.json # 以下の内容を設定

{
  "compilerOptions": {
    "target": "es6",
    "outDir": "./dist",
    "rootDir": "./src",
    "module": "commonjs",
    "downlevelIteration": true,
    "strict": true,
    "noUnusedLocals": true,
    "esModuleInterop": true,
    "moduleResolution": "node"
  },
  "include": ["./src/**/*"],
  "exclude": ["node_modules", "**/*.test.ts"]
}

# git
$ git init
$ touch .gitignore
$ vim .gitignore # 以下の内容を設定

node_modules/
coverage/
.DS_Store
dist/

プロジェクトの設定(reference-proj のみ)

reference-proj にのみ、以下のプロジェクト設定を行います。

プロジェクトの設定(reference-proj のみ)

# bablel
$ touch babel.config.js
$ vim babel.config.js  # 以下の内容を設定

module.exports = {
    presets: [
        ["@babel/preset-env", { targets: { node: "current" } }],
        "@babel/preset-typescript",
    ],
};


# babel
$ touch .babelrc
$ vim .babelrc  # 以下の内容を設定

{
  "presets": ["@babel/preset-typescript"]
}

# jest
$ touch jest.config.js
$ vim jest.config.js  # 以下の内容を設定

module.exports = {
    testMatch: ["<rootDir>/test/**/*.test.ts?(x)"],
};


$ mkdir .vscode
$ touch .vscode/settings.json
$ vim .vscode/settings.json

{
  "jest.jestCommandLine": "npm run test --",
  "jest.autoRun": { "watch": true }
}


# webpack
$ touch webpack.config.js
$ vim webpack.config.js # 以下の内容を設定


const path = require('path');
const NodePolyfillPlugin = require('node-polyfill-webpack-plugin');
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');

module.exports = {
  mode: 'development', 
  devtool: false,
  context: __dirname,
  entry: './src/index.ts',
  output: {
    path: path.join(__dirname, 'dist'),
    filename: 'index.js',
  },
  resolve: {
    extensions: ['.ts', '.js', '.json'],
    fallback: {
      fs: false,
      readline: false,
      perf_hooks: false,
      child_process: false,
      http2: false,
      net: false,
      tls: false,
      async_hooks: false,
      dgram: false,
      cluster: false,
      url: false,
      module: false,
    },
    plugins: [new TsconfigPathsPlugin({})],
  },
  module: {
    rules: [
      {
        test: /\.[tj]s$/,
        exclude: /node_modules/,
        loader: 'babel-loader',
      },
    ],
  },
  plugins: [new NodePolyfillPlugin()],
};


# TypeScript を1つの JavaScript にまとめる設定
$ touch src/index.ts
$ vim src/index.ts # 以下の内容を設定

/**
* 1つのJavaScripにまとめたいTypeScriptを定義する
*/
// 例
// import sum from './sum'; 

// globalの型定義
declare const global: {
  [x: string]: unknown;
};

// 公開したい関数を定義
// global.sum = sum;



# package.json を修正
$ vim package.json # 以下の内容を設定

"scripts": {
    "build": "webpack",
    "test": "jest"
},

動作確認

まずは、reference-projjest を含め正しく動作するか確認します。

コードがいかにも Java っぽいですが。。

src/entity/InnerEntity.ts を作成します。

/**
 * プロジェクト内部のエンティティ
 */
export class InnerEntity {
  public name: string;

  /**
   * コンストラクタ
   *
   * @param name 名前
   */
  constructor(name: string) {
    this.name = name;
  }
}

src/util/JsonUtil.ts を作成します。

/**
 * JSONのユーティリティ
 */
export class JsonUtil {
  /**
   * JSON.stringifyした結果を返す
   *
   * @param object オブジェクト
   * @returns JSON.stringifyした結果を返す
   */
  public static stringify(object?: any): string {
    return JSON.stringify(object);
  }

jestJsonUtil のテストクラスを、 test/util/JsonUtil.test.ts で作成します。

import { describe, test, expect } from '@jest/globals';
import { InnerEntity } from '../../src/entity/InnerEntity';
import { JsonUtil } from '../../src/util/JsonUtil';

describe('JsonUtilのテスト', () => {
  test('stringifyで想定した結果が返ってくる', () => {
    // 準備
    const expected = '{"name":"hoge"}';
    const entity = new InnerEntity('hoge');

    // 実行
    const actual = JsonUtil.stringify(entity);

    // 検証
    expect(actual).toEqual(expected);
  });
});

テストを実行してみると、問題なく実行されることが分かります。

$ npm run test

 PASS  test/util/JsonUtil.test.ts
  JsonUtilのテスト
    ✓ stringifyで想定した結果が返ってくる (1 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        2.303 s

TypeScript のプロジェクト参照を設定する

参照先プロジェクトの設定(common-entity)

tsconfig.json をプロジェクト参照で参照可能になるよう編集します。

$ vim tsconfig.json

{
  "compilerOptions": {
    // プロジェクト参照の参照先は以下の3行を設定する
    "declaration": true,
    "declarationMap": true,
    "composite": true
  }
}

また、src/entity/OuterEntity.ts も作成しておきます。

/**
 * プロジェクト外部のエンティティ
 */
export class OuterEntity {
  public age: number;

  /**
   * コンストラクタ
   *
   * @param age: 年齢
   */
  constructor(age: number) {
    this.age = age;
  }
}

TypeScript のビルドを行い、参照元プロジェクトで参照できるようにします。

$ tsc --build

参照元プロジェクトの設定(reference-proj)

tsconfig.json をプロジェクト参照で参照可能になるよう編集します。

$ vim tsconfig.json

{
  "compilerOptions": {
    // プロジェクト参照の参照元は以下の2行を追加する
    "baseUrl": ".",
    "allowJs": true,
  }
  // 参照先のプロジェクトの相対パスを追加する
  "references": [
    {
      "path": "../common-entity"
    }
  ]
}

では、先ほどの JsonUtil.test.ts に、参照先の common-entity にある OuterEntity を使用したテストを追加してみましょう。

import { describe, test, expect } from '@jest/globals';
import { InnerEntity } from '../../src/entity/InnerEntity';
import { JsonUtil } from '../../src/util/JsonUtil';
import { OuterEntity } from '../../../common-entity/dist/entity/OuterEntity';

describe('JsonUtilのテスト', () => {
  // 追加
  test('プロジェクト参照先のインスタンスをが想定した結果が返ってくる', () => {
    // 準備
    const expected = '{"age":1}';
    const entity = new OuterEntity(1);

    // 実行
    const actual = JsonUtil.stringify(entity);

    // 検証
    expect(actual).toEqual(expected);
  });
});

テストを実行してみると、問題なく使用することができていますね。

$ npm run test 

 PASS  test/util/JsonUtil.test.ts
  JsonUtilのテスト
    ✓ stringifyで想定した結果が返ってくる (1 ms)
    ✓ プロジェクト参照先のインスタンスをが想定した結果が返ってくる (1 ms)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        0.362 s, estimated 1 s

これで OK と言いたいところですが、import 文の相対パスがうっとうしいですね。

import { InnerEntity } from '../../src/entity/InnerEntity';
import { JsonUtil } from '../../src/util/JsonUtil';
import { OuterEntity } from '../../../common-entity/dist/entity/OuterEntity';

うっとうしいだけではなく、上記のプロジェクト参照で dist ディレクトリを指していますが、これを間違えて src を指定してしまうとコンパイルは通るのに実行時エラーとなり、かなり原因追究に時間がかかります。ですので、相対パスにエイリアスをつけて簡単に参照できるようにしましょう。

TypeScript のプロジェクト参照にエイリアスをつける方法

エイリアスの設定は、参照元プロジェクトの reference-proj で行います。

今回は、参照先プロジェクトの common-entity@common-entity、同じプロジェクトの src@src のエイリアスをつけてみましょう。

なお、単純にエイリアスの設定を行うと jest で動作しないので、その課題も含めエイリアスの設定を行います。

tsconfig.json を以下のように修正します。

なお、common-enitity の設定で dist ディレクトリまで指定していることにご注目ください。これで参照先のディレクトリの指定間違いがなくなります。

{
  "compilerOptions": {

    // tsc --build が通らなくなるので修正
    // "rootDir": "./src",
    "rootDir": ".",

    // エイリアスの設定を相対パスで指定
    "paths": {
      "@common-entity/*": ["../common-entity/dist/*"],
      "@src/*": ["src/*"]
    }

  },

  // include と exclude を修正
  // "include": ["./src/**/*"],
  // "exclude": ["node_modules", "**/*.test.ts"],
  "include": ["./src/**/*", "./test/**/*"],
  "exclude": ["node_modules"],
}

なお、この設定だと、TypeScript でビルドした場合、テストコードがビルドに含まれていまいます。

TypeScript のビルドでテストコードを除外する方法は、下記記事が詳しいです。

webpack でビルドした場合はテストコードは含まれないようです。

とは言え、念のために webpack でテストコードの除外設定をして、プロジェクト参照とエイリアスの設定も追加します。

webpack.config.js を以下のように修正します。

module.exports = {
  resolve: {
    // エイリアスの設定を追加
    alias: {
      '@common-entity': path.resolve(__dirname, '../common-entity/dist'),
      '@src': path.resolve(__dirname, './src'),
    },
  },
  module: {
    rules: [
      {
        test: /\.[tj]s$/,

        // exclude にテストも追加
        // exclude: /node_modules/,
        exclude: /(node_modules | test)/,

        loader: 'babel-loader',

        // include を追加
        include: [__dirname, path.resolve(__dirname, '../common-entity/dist')],
      },
    ],
  },

}

jest.config.js を以下のように修正します。

module.exports = {
  testMatch: ['<rootDir>/test/**/*.test.ts?(x)'],
  // エイリアスの設定を追加する
  moduleNameMapper: {
    '^@common-entity(.*)$': '<rootDir>/../common-entity/dist$1',
    '^@src/(.*)$': '<rootDir>/src/$1',
  },
};

下記コマンドを使用して、webpack でビルドを行なってみると問題なくビルドされていますね。

$ npm run build

> reference-proj@1.0.0 build
> webpack

asset index.js 4.08 KiB [compared for emit] (name: main)
runtime modules 891 bytes 4 modules
cacheable modules 535 bytes
  ./src/index.ts 256 bytes [built] [code generated]
  ./src/util/JsonUtil.ts 279 bytes [built] [code generated]
webpack 5.74.0 compiled successfully in 404 ms

jest でプロジェクト参照のエイリアスを使用する

現在、JsonUtil.test.tsimport は以下のようになっています。相対パスが多く見づらいですね。

import { describe, test, expect } from '@jest/globals';
import { InnerEntity } from '../../src/entity/InnerEntity';
import { JsonUtil } from '../../src/util/JsonUtil';
import { OuterEntity } from '../../../common-entity/dist/entity/OuterEntity';

これをエイリアスで指定します。

import { describe, test, expect } from '@jest/globals';
import { InnerEntity } from '@src/entity/InnerEntity';
import { JsonUtil } from '@src/util/JsonUtil';
import { OuterEntity } from '@common-entity/entity/OuterEntity';

テストを実行します。

$ npm run test

 PASS  test/util/JsonUtil.test.ts
  JsonUtilのテスト
    ✓ stringifyで想定した結果が返ってくる (3 ms)
    ✓ プロジェクト参照先のインスタンスをが想定した結果が返ってくる

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        1.801 s

無事に jest のテストを実行することができました。

おわりに

TypeScript で外部のプロジェクト参照とエイリアス設定は使う頻度が多そうなのに、情報が本当になくてかなり試行錯誤することになってしまいました。

着想から解決まで、2ヶ月半くらいかかっているかもしれません。

なお、ソースコードは、GitHub に上げましたので参考にしてみてください。