30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

Yarn のワークスペースの初歩

Yarn にはワークスペースという機能があり、これを使うとひとつのリポジトリで複数の npm パッケージを開発できるようになり、効率的に作業を進められるようになる。

この記事の内容は、Yarn のv1.22.4で動作確認している。

シンボリックリンクが作られ、パッケージをまたいだ開発をしやすくなる

ワークスペースをつかうためには、リポジトリのルートディレクトリにpackage.jsonを作成し、workspacesフィールドを指定する必要がある。
また、このディレクトリをパッケージとして公開することはないので、privateフィールドをtrueにしておく。

{
  "private": true,
  "workspaces": ["project-a"]
}

上記の例では、project-aというディレクトリを、ワークスペースの対象としている。
次に、以下の内容の./project-a/package.jsonを作成する。

{
  "name": "project-a",
  "version": "1.0.0"
}

現在のディレクトリ構成は以下のようになっている。

├── package.json
└── project-a
    └── package.json

この状態で、ルートディレクトリで$ yarnを実行する。
すると、./yarn.lock./node_modulesが作られる。

注目すべきは./node_modulesで、そのなかにproject-aというファイルが作られ、それは./project-aへのシンボリックリンクになっている。

$ ls -l node_modules/
total 0
lrwxr-xr-x  1 numb  staff  12  7 20 17:32 project-a -> ../project-a

$ cat node_modules/project-a/package.json
{
  "name": "project-a",
  "version": "1.0.0"
}

これだけだと何がありがたいのかイメージしづらいが、このリポジトリ内の他のプロジェクトがproject-aに依存しているときに、便利になる。

早速、project-aに依存するproject-bを作っていく。

まず、./project-a/foo.jsを作る。

module.exports = {
  x: 1,
}

そして、./project-a/package.jsonmainフィールドにfoo.jsを指定する。

{
  "name": "project-a",
  "version": "1.0.0",
  "main": "./foo.js"
}

これで、project-aを npm パッケージとして公開した時は、require('project-a')foo.jsが読み込まれるようになる。

次に、以下の内容の./project-b/package.json./project-b/bar.jsを作る。

{
  "name": "project-b",
  "version": "1.0.0",
  "main": "./bar.js",
  "dependencies": {
    "project-a": "1.0.0"
  }
}
const {x} = require('project-a');

console.log(x);

dependenciesに書いたように、project-bproject-aに依存しており、それを読み込んでいる。
だが、project-aはまだ npm パッケージとして公開していない。
この状況で./project-b/bar.jsを実行するとどうなるか。

$ node project-b/bar.js
1

問題なく動作する。
なぜなら./node_modulesにはproject-aが存在しており、それは./project-aへのシンボリックリンクになっているためである。

この仕組みによって、パッケージをまたいだ修正や変更がやりやすくなる。

例えば、project-aを公開したあとに、xではなくyという変数をエクスポートすべきであったことに気付き、修正することになったとする。
そうすると、その修正の影響はproject-bにも及ぶ。
こういったケースでの動作確認を、ワークスペースの仕組みによって非常に簡単に行える。

まず、./project-a/foo.jsを修正する。

module.exports = {
  y: 1,
}

そうすると、その変更は自動的にproject-bにも反映される。

$ node project-b/bar.js
undefined

xではなくyを読み込めばよいはずなので、./project-b/bar.jsを修正する。

const {y} = require('project-a');

console.log(y);

無事に動くことを確認できた。

$ node project-b/bar.js
1

このように、手元の環境での動作確認が非常に簡単になる。

ルートディレクトリの yarn.lock で依存パッケージを一元管理できる

ワークスペースを使うと、依存パッケージの管理も簡便になる。
パッケージ毎にロックファイルを作成するのではなく、ルートディレクトリに用意したひとつのロックファイルで管理していくことになる。

まず、./package.jsonを更新して、project-bもワークスペースの対象にする。

{
  "private": true,
  "workspaces": [
    "project-a",
    "project-b"
  ]
}

そしてそれぞれのプロジェクトの依存関係に、currency.js@2.0.0を加える。

{
  "name": "project-a",
  "version": "1.0.0",
  "main": "./foo.js",
  "dependencies": {
    "currency.js": "2.0.0"
  }
}
{
  "name": "project-b",
  "version": "1.0.0",
  "main": "./bar.js",
  "dependencies": {
    "project-a": "1.0.0",
    "currency.js": "2.0.0"
  }
}

この状態で、ルートディレクトリで$ yarnを実行する。
そうすると、ルートディレクトリのyarn.lockに以下のように記述される。

# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1


currency.js@2.0.0:
  version "2.0.0"
  resolved "https://registry.yarnpkg.com/currency.js/-/currency.js-2.0.0.tgz#f23b5d8b963519e419ee051103fa44168eb5a8f1"
  integrity sha512-W1OqppUNYbxcTVKXI4oV9dkBlg/GWhYpEbvKzDl79JeljAEI6KNdjreX66/s59C7Fg+MjDwhV6RbT/gLVZJg9A==

そして、node_modulesも各パッケージ毎に作成されるのではなく、ルートディレクトリにあるnode_modulesに、currency.jsがインストールされる。

$ ls node_modules/
currency.js project-a   project-b

そしてもちろん、問題なく読み込める。

// ./project-b/bar.js
const currency = require('currency.js');
const {y} = require('project-a');

console.log(currency);
console.log(y);
$ node project-b/bar.js
[Function: currency]
1

今回は両方のプロジェクトがcurrency.jsに依存していたが、ひとつのプロジェクトでしか依存していないパッケージも、ルートディレクトリのyarn.lockで管理し、ルートディレクトリのnode_modulesにインストールされる。

異なるバージョンのパッケージに依存している場合の挙動

./project-a/package.jsonを以下のように書き換えてみる。

{
  "name": "project-a",
  "version": "1.0.0",
  "main": "./foo.js",
  "dependencies": {
    "currency.js": "1.2.1"
  }
}

こうすると、project-aproject-bでそれぞれ、バージョンの異なるcurrency.jsに依存している状態になる。

この状態で、ルートディレクトリで$ yarnを実行する。

そうすると、./project-b/node_modules/currency.js/が作られ、そこにcurrency.js@2.0.0がインストールされる。

$ cat project-b/node_modules/currency.js/package.json | grep 'version'
  "version": "2.0.0",

./node_modules/には、currency.js@1.2.1がインストールされる。

$ cat ./node_modules/currency.js/package.json | grep 'version'
  "version": "1.2.1",

そしてルートディレクトリにあるyarn.lockに、両方のバージョンが記述される。

# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1


currency.js@1.2.1:
  version "1.2.1"
  resolved "https://registry.yarnpkg.com/currency.js/-/currency.js-1.2.1.tgz#18d42ac74d833795051f4a310c83f041013a882f"
  integrity sha512-7t0jYDZeQYCcaxpgwNOiBz8GaG/qdqTdo+kcTYgCuCNx1p2jLMpJt8h34TJ5INtN9jhryAiLK/atmjwUf13t4A==

currency.js@2.0.0:
  version "2.0.0"
  resolved "https://registry.yarnpkg.com/currency.js/-/currency.js-2.0.0.tgz#f23b5d8b963519e419ee051103fa44168eb5a8f1"
  integrity sha512-W1OqppUNYbxcTVKXI4oV9dkBlg/GWhYpEbvKzDl79JeljAEI6KNdjreX66/s59C7Fg+MjDwhV6RbT/gLVZJg9A==

project-aのプログラムがcurrency.jsを読み込む際、まず./project-a/node_modules/currency.jsを探す。
だが見つからない(そもそも./project-a/node_modulesがない)ので、親の階層のnode_modulesを探索する。
すると./node_modules/currency.jsが見つかるので、1.2.1currency.jsを読み込む。
同じロジックで、project-bのプログラムがcurrency.jsを読み込むと、2.0.0が読み込まれる。

yarn workspace コマンド

ここまで、動作確認のためにpackage.jsonを編集して依存パッケージを加えてきたが、一般的には$ yarn addコマンドでパッケージをインストールすることが多い。
ワークスペースを利用していても、それぞれのプロジェクトに移動すれば、通常通りインストールできる。

$ cd project-a
$ yarn add reselect

この場合も、ルートディレクトリのyarn.lockに記録され、ルートディレクトリのnode_modulesにインストールされる。

ルートディレクトリにいるときにインストールするには、$ yarn workspace プロジェクト名 add パッケージ名とすればよい。

ルートにいるときに以下のコマンドを実行すると、project-bdependenciesredux-thunkを追加する。

$ yarn workspace project-b add redux-thunk
$ cat project-b/package.json
{
  "name": "project-b",
  "version": "1.0.0",
  "main": "./bar.js",
  "dependencies": {
    "currency.js": "2.0.0",
    "project-a": "1.0.0",
    "redux-thunk": "^2.3.0"
  }
}

実行できるサブコマンドはaddだけでなく、$ yarn workspace プロジェクト名 サブコマンドで、任意のサブコマンドを実行できる。
例えば、xxxというプロジェクトからfooというパッケージへの依存を削除するには、以下のようにする。

$ yarn workspace xxx remove foo

各プロジェクトのpackage.jsonではなく、ルートディレクトリにあるpackage.jsonに依存関係を追加する時は、--ignore-workspace-root-checkフラグ(省略形は-W)を使う。

$ yarn add -D -W eslint
$ cat ./package.json
{
  "private": true,
  "workspaces": [
    "project-a",
    "project-b"
  ],
  "devDependencies": {
    "eslint": "^7.5.0"
  }
}

yarn workspaces run コマンド

$ yarn workspaces run スクリプト名で、各プロジェクトで定義してあるスクリプトを一度に実行できる。

試しに、各package.jsonscriptsフィールドで、fizzというスクリプトを定義する。

{
  "private": true,
  "workspaces": [
    "project-a",
    "project-b"
  ],
  "scripts": {
    "fizz": "echo fizz!! by root"
  },
  "devDependencies": {
    "eslint": "^7.5.0"
  }
}
{
  "name": "project-a",
  "version": "1.0.0",
  "main": "./foo.js",
  "scripts": {
    "fizz": "echo fizz!! by a"
  },
  "dependencies": {
    "currency.js": "1.2.1",
    "reselect": "^4.0.0"
  }
}
{
  "name": "project-b",
  "version": "1.0.0",
  "main": "./bar.js",
  "scripts": {
    "fizz": "echo fizz!! by b"
  },
  "dependencies": {
    "currency.js": "2.0.0",
    "project-a": "1.0.0",
    "redux-thunk": "^2.3.0"
  }
}

この状態で$ yarn workspaces run fizzを実行すると、次のような結果になる。

$ yarn workspaces run fizz
yarn workspaces v1.22.4

> project-a
yarn run v1.22.4
warning package.json: No license field
$ echo fizz!! by a
fizz!! by a
✨  Done in 0.03s.

> project-b
yarn run v1.22.4
warning package.json: No license field
$ echo fizz!! by b
fizz!! by b
✨  Done in 0.03s.
✨  Done in 0.43s.

./project-a/package.json./project-b/package.jsonに定義してあるfizzスクリプトが実行される。
./package.jsonfizzスクリプトは実行されない。これを実行するには、ルートディレクトリで$ yarn run fizzを実行すればよい。

$ yarn run fizz
yarn run v1.22.4
$ echo fizz!! by root
fizz!! by root
✨  Done in 0.03s.

参考資料

TypeScript の型定義ファイルの探索アルゴリズム

npm パッケージは基本的に、JavaScript ファイルで配布されている。TypeScript で開発しているパッケージであっても、JavaScript にビルドしたものを配布している。
そのため、型定義ファイルによって型付けしないと、インポートした際にモジュール全体がanyになってしまう。
これでは型システムの恩恵を受けることができないし、noImplicitAnyフラグをfalseにしていない場合はコンパイルエラーになってしまう。

npm パッケージをインポートした際、TypeScript は自動的に型定義ファイルを探索し、最初に見つかったものを使用する。
また、プロジェクト内にある JavaScript ファイルをインポートした際も、型定義ファイルの探索が行われる。

この記事では、TypeScript がどのように型定義ファイルを探索するのか、実際に検証して確認していく。

動作確認に使った TypeScript のバージョンは3.9.6
使っているライブラリのバージョンは以下の通り。

  • redux@4.0.5
  • react-redux@7.2.0
  • @types/react-redux@7.1.9

tsconfig.jsonは以下。
後述するように、tsconfig.jsonの設定は探索アルゴリズムに影響を与える。

{
  "compilerOptions": {
    "target": "ESNext",
    "outDir": "./dist",
    "moduleResolution": "Node",
    "strict": true
  },
  "include": ["./src/**/*"]
}

型定義ファイルが同梱されているパッケージで検証

npm パッケージのなかには、型定義ファイルが同梱されているものもある。reduxもそのひとつ。

reduxパッケージさえインストールしておけば、特に何もしなくても自動的に型付けされる。

import {createStore} from 'redux';

const x = createStore; // const x: StoreCreator

このとき TypeScript は、node_modules/redux/index.d.tsを使って型付けしている。
そのため、このファイルの内容を書き換えると、その変更が反映される。

試しに、createStoreの定義を変更してみる。

export const createStore: number

そうするとcreateStoreの型はnumberになるため、number型であるxに代入できる。

import {createStore} from 'redux';

const x: number = createStore; // ok

package.json で型定義ファイルを指定する

インストールしたパッケージのpackage.jsontypesフィールド、もしくはtypingsフィールドで、使用する型定義ファイルを指定することができる。

node_modules/redux/package.jsontypingsフィールドを見ていると、./index.d.tsが指定されている。

$ cat node_modules/redux/package.json | grep '"typings"'
  "typings": "./index.d.ts",

そのため、node_modules/redux/index.d.tsを型定義ファイルとして使用していたのである。
ではこれを"typings": "./foo.d.ts",に書き換えるとどうなるか。

実はそれでも、node_modules/redux/index.d.tsが使用され、コンパイルエラーにはならない。
typings(もしくはtypes)フィールドで指定した型定義ファイルが見つからなかった場合、あるいはtypingstypesフィールドが存在しない場合、そのモジュールのルートにあるindex.d.tsを探す。
今回のケースではtypingsフィールドで指定されている./foo.d.tsが見つからなかったため、./index.d.tsを探し、それを使用した。

アンビエントモジュール宣言

declare module 'パッケージ名'という記法を使って、パッケージの型を自分で定義することもできる。
これを、アンビエントモジュール宣言という。

まず、アンビエントモジュール宣言を書かずに、以下のsrc/index.tsを書いてみる。

import {createStore, Action} from 'redux';

const x = createStore; // const x: StoreCreator
type A = Action; // type A = Action<any>

既述したように、node_modules/redux/index.d.tsを型定義ファイルとして使用しており、このような結果になる。

次に、src/types.d.tsを作成し、以下のように書く。

declare module 'redux' {
  export const createStore: boolean;
}

そうすると、このアンビエントモジュール宣言を使って型付けするため、先程のコードはコンパイルエラーになってしまう。

// Module '"redux"' has no exported member 'Action'.ts(2305)
import {createStore, Action} from 'redux';

const x: boolean = createStore; // ok
type A = Action;

createStorebooleanとして定義しているため問題ないが、Actionは存在しないため、コンパイルエラーになってしまう。

アンビエントモジュール宣言を以下のように書き換えれば、エラーは消える。

declare module 'redux' {
  export const createStore: boolean;
  export type Action = string;
}
import {createStore, Action} from 'redux';

const x: boolean = createStore; // ok
type A = Action; // type A = string

この挙動から分かるように、パッケージに同梱されている型定義ファイルよりも、アンビエントモジュール宣言が優先される。

型定義ファイルが DefinitelyTyped で管理されているパッケージで検証

次は、型定義ファイルがパッケージに同梱されておらず、DefinitelyTyped で管理されているパッケージで検証してみる。
具体的には、react-reduxで検証する。

インストールしてnode_modules/react-reduxを見てみると分かるが、型定義ファイルは同梱されていない。
そのため、そのままインポートするとコンパイルエラーになる。

// Could not find a declaration file for module 'react-redux'.
import {useSelector} from 'react-redux';

react-reduxの型定義ファイルは DefinitelyTyped で管理されているので、@types/react-reduxをインストールすればよい。

そしてインストールするとそれだけで、先程のコードのコンパイルエラーが消える。
つまり、node_modules/@types/react-reduxにある型定義ファイルを、TypeScript が自動的に読み込んでくれている。

node_modules/@types/react-redux/package.jsonを見てみるとtypesフィールドでindex.d.tsが指定されており、node_modules/@types/react-redux/index.d.tsを型定義ファイルとして指定していることが分かる。

$ cat node_modules/@types/react-redux/package.json | grep '"types"'
    "types": "index.d.ts",

そのため、先程と同様、このファイルを書き換えればそれが反映される。

typestypings)フィールドでの指定がない、もしくは指定したファイルが存在しない場合は、index.d.tsを探す。これも、先程と同じである。

次に、以下の内容のnode_modules/react-redux/index.d.tsを作成してみる。

export function useSelector(): number;

そうすると、useSelectornumberを返す関数になる。

import {useSelector} from 'react-redux';

const x: number = useSelector(); // ok

このことから、パッケージに同梱されている型定義ファイルが優先されることが分かる。
今回の例で言えば、node_modules/@types/react-redux/index.d.tsよりもnode_modules/react-redux/index.d.tsが優先される。

JavaScript ファイルと同名の型定義ファイル

この状態で今度は、以下の内容のnode_modules/react-redux/lib/index.d.tsを作成する。

export function useSelector(): boolean;

そうすると、useSelectorの返り値はbooleanになる。

import {useSelector} from 'react-redux';

const x: boolean = useSelector(); // ok

なぜこのような挙動になるのかと言うと、TypeScript は、インポートしている.jsファイルと同じディレクトリにある同名の.d.tsを探索するため。
そしてそれは、パッケージに同梱されている型定義ファイルよりも優先される。

react-reduxの場合、node_modules/react-redux/lib/index.jsをインポートしている。
そのため、node_modules/react-redux/lib/index.d.tsを用意すると、それを型定義ファイルとして使用する。

ではこの状態でアンビエントモジュール宣言を行うと、どうなるか。

// src/types.d.ts
declare module 'react-redux' {
  export function useSelector(): string;
}
// src/index.ts
import {useSelector} from 'react-redux';

const x: string = useSelector(); // ok

useSelectorstringを返すようになっている。つまり、アンビエントモジュール宣言が最も優先されるということになる。

探索の優先順位

ここまでの内容をまとめると、以下の優先順位になる。

  1. アンビエントモジュール宣言
  2. インポートしている.jsファイルと同じディレクトリにある、同名の.d.tsファイル
  3. 当該パッケージのpackage.jsontypes(もしくはtypings)フィールドで指定しているファイル
  4. 当該パッケージのルートにあるindex.d.ts
  5. node_modules/@types/当該パッケージ名package.jsontypes(もしくはtypings)フィールドで指定しているファイル
  6. node_modules/@types/当該パッケージ名/index.d.ts

ただ、 typesVersions を使った制御なども存在するため、上記の内容がアルゴリズムの全てというわけではない。

プロジェクト内の JavaScript ファイルをインポートする際の挙動

TypeScript に移行中のプロジェクトなどでは、同一プロジェクト内にある JavaScript ファイルをインポートすることがある。

// src/utils/foo.js
export const add = (x, y) => x + y;
// src/index.ts
import {add} from './utils/foo';

const result = add(3, 4);

この場合、型定義ファイルが存在しないため、コンパイルエラーになる。

allowJsフラグを有効にすればコンパイルエラーは消えるが、全てがanyになってしまう。

import {add} from './utils/foo';

const x = add; // const x: (x: any, y: any) => any
const result = add(3, 4); // const result: any

このような場合、先程説明した「インポートしている.jsファイルと同じディレクトリにある同名の.d.tsファイル」を用意することで、型情報を提供することができる。

src/utils/foo.jsをインポートしているので、src/utils/foo.d.tsを用意すればよい。

export function add(x: number, y: number): number;

これで、型システムの恩恵を受けられるようになった。

import {add} from './utils/foo';

const x = add; // const x: (x: number, y: number) => number
const result = add(3, 4); // const result: number

moduleResolution フラグ

ここまでの内容は全て、moduleResolutionフラグの値を"Node"にした状態で検証してきた。
このフラグは"Node"の他に"Classic"が設定可能だが、そうすると、npm パッケージをインポートする際の挙動が大きく変わり、ここまで紹介してきた場所に型定義ファイルが存在してもコンパイルエラーになってしまう。

// Cannot find module 'redux' or its corresponding type declarations.
import {createStore, Action} from 'redux';

"Classic"は互換性のために存在するだけで、明示的にこの値を設定することはないと思われる。

だが、moduleResolutionの値を指定しなかった場合、他のフラグの設定によっては"Classic"になってしまう。
例えばtsconfig.jsonを以下の内容にすると、moduleResolution"Classic"になってしまい、先程のようなコンパイルエラーが発生してしまう。

{
  "compilerOptions": {
    "target": "ESNext",
    "outDir": "./dist",
    "strict": true,
  },
  "include": ["./src/**/*"]
}

プロジェクトのmoduleResolutionがどのような設定になっているか、注意する必要がある。